UncountablePythonSDK 0.0.52__py3-none-any.whl → 0.0.131__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (316) hide show
  1. docs/conf.py +54 -7
  2. docs/index.md +107 -4
  3. docs/integration_examples/create_ingredient.md +43 -0
  4. docs/integration_examples/create_output.md +56 -0
  5. docs/integration_examples/index.md +6 -0
  6. docs/justfile +2 -2
  7. docs/requirements.txt +6 -4
  8. examples/async_batch.py +3 -3
  9. examples/basic_auth.py +7 -0
  10. examples/create_entity.py +3 -1
  11. examples/create_ingredient_sdk.py +34 -0
  12. examples/download_files.py +26 -0
  13. examples/edit_recipe_inputs.py +4 -2
  14. examples/integration-server/jobs/materials_auto/concurrent_cron.py +11 -0
  15. examples/integration-server/jobs/materials_auto/example_cron.py +21 -0
  16. examples/integration-server/jobs/materials_auto/example_http.py +47 -0
  17. examples/integration-server/jobs/materials_auto/example_instrument.py +100 -0
  18. examples/integration-server/jobs/materials_auto/example_parse.py +140 -0
  19. examples/integration-server/jobs/materials_auto/example_predictions.py +61 -0
  20. examples/integration-server/jobs/materials_auto/example_runsheet_wh.py +39 -0
  21. examples/integration-server/jobs/materials_auto/example_wh.py +23 -0
  22. examples/integration-server/jobs/materials_auto/profile.yaml +104 -0
  23. examples/integration-server/pyproject.toml +224 -0
  24. examples/invoke_uploader.py +4 -1
  25. examples/oauth.py +7 -0
  26. examples/set_recipe_metadata_file.py +40 -0
  27. examples/set_recipe_output_file_sdk.py +26 -0
  28. examples/upload_files.py +1 -2
  29. pkgs/argument_parser/__init__.py +9 -0
  30. pkgs/argument_parser/_is_namedtuple.py +3 -0
  31. pkgs/argument_parser/argument_parser.py +217 -70
  32. pkgs/filesystem_utils/__init__.py +1 -0
  33. pkgs/filesystem_utils/_blob_session.py +144 -0
  34. pkgs/filesystem_utils/_gdrive_session.py +10 -7
  35. pkgs/filesystem_utils/_s3_session.py +15 -13
  36. pkgs/filesystem_utils/_sftp_session.py +11 -7
  37. pkgs/filesystem_utils/file_type_utils.py +30 -10
  38. pkgs/py.typed +0 -0
  39. pkgs/serialization/__init__.py +7 -2
  40. pkgs/serialization/annotation.py +64 -0
  41. pkgs/serialization/missing_sentry.py +1 -1
  42. pkgs/serialization/opaque_key.py +1 -1
  43. pkgs/serialization/serial_alias.py +47 -0
  44. pkgs/serialization/serial_class.py +47 -26
  45. pkgs/serialization/serial_generic.py +16 -0
  46. pkgs/serialization/serial_union.py +17 -14
  47. pkgs/serialization/yaml.py +4 -1
  48. pkgs/serialization_util/__init__.py +6 -0
  49. pkgs/serialization_util/dataclasses.py +14 -0
  50. pkgs/serialization_util/serialization_helpers.py +15 -5
  51. pkgs/type_spec/actions_registry/__main__.py +0 -4
  52. pkgs/type_spec/actions_registry/emit_typescript.py +5 -5
  53. pkgs/type_spec/builder.py +354 -119
  54. pkgs/type_spec/builder_types.py +9 -0
  55. pkgs/type_spec/config.py +51 -11
  56. pkgs/type_spec/cross_output_links.py +99 -0
  57. pkgs/type_spec/emit_io_ts.py +1 -1
  58. pkgs/type_spec/emit_open_api.py +127 -36
  59. pkgs/type_spec/emit_open_api_util.py +5 -6
  60. pkgs/type_spec/emit_python.py +329 -121
  61. pkgs/type_spec/emit_typescript.py +117 -256
  62. pkgs/type_spec/emit_typescript_util.py +291 -2
  63. pkgs/type_spec/load_types.py +18 -4
  64. pkgs/type_spec/non_discriminated_union_exceptions.py +14 -0
  65. pkgs/type_spec/open_api_util.py +29 -4
  66. pkgs/type_spec/parts/base.py.prepart +13 -10
  67. pkgs/type_spec/parts/base.ts.prepart +4 -0
  68. pkgs/type_spec/type_info/__main__.py +3 -1
  69. pkgs/type_spec/type_info/emit_type_info.py +124 -29
  70. pkgs/type_spec/ui_entry_actions/__init__.py +4 -0
  71. pkgs/type_spec/ui_entry_actions/generate_ui_entry_actions.py +308 -0
  72. pkgs/type_spec/util.py +4 -4
  73. pkgs/type_spec/value_spec/__main__.py +26 -9
  74. pkgs/type_spec/value_spec/convert_type.py +21 -1
  75. pkgs/type_spec/value_spec/emit_python.py +25 -7
  76. pkgs/type_spec/value_spec/types.py +1 -1
  77. uncountable/core/async_batch.py +1 -1
  78. uncountable/core/client.py +142 -39
  79. uncountable/core/environment.py +41 -0
  80. uncountable/core/file_upload.py +52 -18
  81. uncountable/integration/cli.py +142 -0
  82. uncountable/integration/construct_client.py +8 -8
  83. uncountable/integration/cron.py +11 -37
  84. uncountable/integration/db/connect.py +12 -2
  85. uncountable/integration/db/session.py +25 -0
  86. uncountable/integration/entrypoint.py +8 -37
  87. uncountable/integration/executors/executors.py +125 -2
  88. uncountable/integration/executors/generic_upload_executor.py +87 -29
  89. uncountable/integration/executors/script_executor.py +3 -3
  90. uncountable/integration/http_server/__init__.py +5 -0
  91. uncountable/integration/http_server/types.py +69 -0
  92. uncountable/integration/job.py +242 -12
  93. uncountable/integration/queue_runner/__init__.py +0 -0
  94. uncountable/integration/queue_runner/command_server/__init__.py +28 -0
  95. uncountable/integration/queue_runner/command_server/command_client.py +133 -0
  96. uncountable/integration/queue_runner/command_server/command_server.py +142 -0
  97. uncountable/integration/queue_runner/command_server/constants.py +4 -0
  98. uncountable/integration/queue_runner/command_server/protocol/__init__.py +0 -0
  99. uncountable/integration/queue_runner/command_server/protocol/command_server.proto +58 -0
  100. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py +57 -0
  101. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi +114 -0
  102. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2_grpc.py +264 -0
  103. uncountable/integration/queue_runner/command_server/types.py +75 -0
  104. uncountable/integration/queue_runner/datastore/__init__.py +3 -0
  105. uncountable/integration/queue_runner/datastore/datastore_sqlite.py +250 -0
  106. uncountable/integration/queue_runner/datastore/interface.py +29 -0
  107. uncountable/integration/queue_runner/datastore/model.py +24 -0
  108. uncountable/integration/queue_runner/job_scheduler.py +200 -0
  109. uncountable/integration/queue_runner/queue_runner.py +34 -0
  110. uncountable/integration/queue_runner/types.py +7 -0
  111. uncountable/integration/queue_runner/worker.py +116 -0
  112. uncountable/integration/scan_profiles.py +67 -0
  113. uncountable/integration/scheduler.py +199 -0
  114. uncountable/integration/secret_retrieval/retrieve_secret.py +26 -4
  115. uncountable/integration/server.py +94 -69
  116. uncountable/integration/telemetry.py +150 -34
  117. uncountable/integration/webhook_server/entrypoint.py +97 -0
  118. uncountable/types/__init__.py +78 -1
  119. uncountable/types/api/batch/execute_batch.py +13 -6
  120. uncountable/types/api/batch/execute_batch_load_async.py +9 -3
  121. uncountable/types/api/chemical/convert_chemical_formats.py +17 -5
  122. uncountable/types/api/condition_parameters/__init__.py +1 -0
  123. uncountable/types/api/condition_parameters/upsert_condition_match.py +72 -0
  124. uncountable/types/api/entity/create_entities.py +19 -7
  125. uncountable/types/api/entity/create_entity.py +17 -8
  126. uncountable/types/api/entity/create_or_update_entity.py +48 -0
  127. uncountable/types/api/entity/export_entities.py +59 -0
  128. uncountable/types/api/entity/get_entities_data.py +13 -4
  129. uncountable/types/api/entity/grant_entity_permissions.py +48 -0
  130. uncountable/types/api/entity/list_aggregate.py +79 -0
  131. uncountable/types/api/entity/list_entities.py +42 -10
  132. uncountable/types/api/entity/lock_entity.py +11 -4
  133. uncountable/types/api/entity/lookup_entity.py +116 -0
  134. uncountable/types/api/entity/resolve_entity_ids.py +15 -6
  135. uncountable/types/api/entity/set_entity_field_values.py +44 -0
  136. uncountable/types/api/entity/set_values.py +10 -3
  137. uncountable/types/api/entity/transition_entity_phase.py +22 -7
  138. uncountable/types/api/entity/unlock_entity.py +10 -3
  139. uncountable/types/api/equipment/associate_equipment_input.py +9 -3
  140. uncountable/types/api/field_options/upsert_field_options.py +17 -7
  141. uncountable/types/api/files/__init__.py +1 -0
  142. uncountable/types/api/files/download_file.py +77 -0
  143. uncountable/types/api/id_source/list_id_source.py +16 -7
  144. uncountable/types/api/id_source/match_id_source.py +14 -5
  145. uncountable/types/api/input_groups/get_input_group_names.py +13 -4
  146. uncountable/types/api/inputs/create_inputs.py +23 -9
  147. uncountable/types/api/inputs/get_input_data.py +30 -12
  148. uncountable/types/api/inputs/get_input_names.py +16 -7
  149. uncountable/types/api/inputs/get_inputs_data.py +25 -7
  150. uncountable/types/api/inputs/set_input_attribute_values.py +12 -6
  151. uncountable/types/api/inputs/set_input_category.py +12 -5
  152. uncountable/types/api/inputs/set_input_subcategories.py +10 -3
  153. uncountable/types/api/inputs/set_intermediate_type.py +11 -4
  154. uncountable/types/api/integrations/__init__.py +1 -0
  155. uncountable/types/api/integrations/publish_realtime_data.py +41 -0
  156. uncountable/types/api/integrations/push_notification.py +49 -0
  157. uncountable/types/api/integrations/register_sockets_token.py +41 -0
  158. uncountable/types/api/listing/__init__.py +1 -0
  159. uncountable/types/api/listing/fetch_listing.py +58 -0
  160. uncountable/types/api/material_families/update_entity_material_families.py +10 -4
  161. uncountable/types/api/notebooks/__init__.py +1 -0
  162. uncountable/types/api/notebooks/add_notebook_content.py +119 -0
  163. uncountable/types/api/outputs/get_output_data.py +28 -13
  164. uncountable/types/api/outputs/get_output_names.py +15 -6
  165. uncountable/types/api/outputs/get_output_organization.py +173 -0
  166. uncountable/types/api/outputs/resolve_output_conditions.py +20 -8
  167. uncountable/types/api/permissions/set_core_permissions.py +26 -10
  168. uncountable/types/api/project/get_projects.py +16 -7
  169. uncountable/types/api/project/get_projects_data.py +17 -8
  170. uncountable/types/api/recipe_links/create_recipe_link.py +12 -5
  171. uncountable/types/api/recipe_links/remove_recipe_link.py +11 -4
  172. uncountable/types/api/recipe_metadata/get_recipe_metadata_data.py +16 -7
  173. uncountable/types/api/recipes/add_recipe_to_project.py +10 -3
  174. uncountable/types/api/recipes/add_time_series_data.py +64 -0
  175. uncountable/types/api/recipes/archive_recipes.py +11 -4
  176. uncountable/types/api/recipes/associate_recipe_as_input.py +12 -5
  177. uncountable/types/api/recipes/associate_recipe_as_lot.py +10 -3
  178. uncountable/types/api/recipes/clear_recipe_outputs.py +42 -0
  179. uncountable/types/api/recipes/create_mix_order.py +44 -0
  180. uncountable/types/api/recipes/create_recipe.py +15 -9
  181. uncountable/types/api/recipes/create_recipes.py +21 -9
  182. uncountable/types/api/recipes/disassociate_recipe_as_input.py +10 -3
  183. uncountable/types/api/recipes/edit_recipe_inputs.py +134 -22
  184. uncountable/types/api/recipes/get_column_calculation_values.py +57 -0
  185. uncountable/types/api/recipes/get_curve.py +11 -5
  186. uncountable/types/api/recipes/get_recipe_calculations.py +13 -7
  187. uncountable/types/api/recipes/get_recipe_links.py +10 -4
  188. uncountable/types/api/recipes/get_recipe_names.py +13 -4
  189. uncountable/types/api/recipes/get_recipe_output_metadata.py +12 -6
  190. uncountable/types/api/recipes/get_recipes_data.py +87 -33
  191. uncountable/types/api/recipes/lock_recipes.py +19 -8
  192. uncountable/types/api/recipes/remove_recipe_from_project.py +10 -3
  193. uncountable/types/api/recipes/set_recipe_inputs.py +16 -10
  194. uncountable/types/api/recipes/set_recipe_metadata.py +10 -3
  195. uncountable/types/api/recipes/set_recipe_output_annotations.py +24 -12
  196. uncountable/types/api/recipes/set_recipe_output_file.py +55 -0
  197. uncountable/types/api/recipes/set_recipe_outputs.py +35 -12
  198. uncountable/types/api/recipes/set_recipe_tags.py +26 -9
  199. uncountable/types/api/recipes/set_recipe_total.py +59 -0
  200. uncountable/types/api/recipes/unarchive_recipes.py +10 -3
  201. uncountable/types/api/recipes/unlock_recipes.py +14 -6
  202. uncountable/types/api/runsheet/__init__.py +1 -0
  203. uncountable/types/api/runsheet/complete_async_upload.py +41 -0
  204. uncountable/types/api/triggers/run_trigger.py +11 -4
  205. uncountable/types/api/uploader/complete_async_parse.py +46 -0
  206. uncountable/types/api/uploader/invoke_uploader.py +13 -6
  207. uncountable/types/api/user/__init__.py +1 -0
  208. uncountable/types/api/user/get_current_user_info.py +40 -0
  209. uncountable/types/async_batch.py +2 -1
  210. uncountable/types/async_batch_processor.py +618 -18
  211. uncountable/types/async_batch_t.py +54 -7
  212. uncountable/types/async_jobs.py +8 -0
  213. uncountable/types/async_jobs_t.py +52 -0
  214. uncountable/types/auth_retrieval.py +11 -0
  215. uncountable/types/auth_retrieval_t.py +75 -0
  216. uncountable/types/base.py +0 -1
  217. uncountable/types/base_t.py +13 -11
  218. uncountable/types/calculations.py +0 -1
  219. uncountable/types/calculations_t.py +5 -2
  220. uncountable/types/chemical_structure.py +0 -1
  221. uncountable/types/chemical_structure_t.py +6 -5
  222. uncountable/types/client_base.py +751 -70
  223. uncountable/types/client_config.py +1 -1
  224. uncountable/types/client_config_t.py +17 -3
  225. uncountable/types/curves.py +0 -1
  226. uncountable/types/curves_t.py +10 -7
  227. uncountable/types/data.py +12 -0
  228. uncountable/types/data_t.py +103 -0
  229. uncountable/types/entity.py +4 -1
  230. uncountable/types/entity_t.py +125 -7
  231. uncountable/types/experiment_groups.py +0 -1
  232. uncountable/types/experiment_groups_t.py +5 -2
  233. uncountable/types/exports.py +8 -0
  234. uncountable/types/exports_t.py +34 -0
  235. uncountable/types/field_values.py +19 -1
  236. uncountable/types/field_values_t.py +246 -9
  237. uncountable/types/fields.py +0 -1
  238. uncountable/types/fields_t.py +5 -2
  239. uncountable/types/generic_upload.py +6 -1
  240. uncountable/types/generic_upload_t.py +88 -9
  241. uncountable/types/id_source.py +0 -1
  242. uncountable/types/id_source_t.py +26 -7
  243. uncountable/types/identifier.py +0 -1
  244. uncountable/types/identifier_t.py +13 -5
  245. uncountable/types/input_attributes.py +0 -1
  246. uncountable/types/input_attributes_t.py +4 -4
  247. uncountable/types/inputs.py +1 -1
  248. uncountable/types/inputs_t.py +24 -4
  249. uncountable/types/integration_server.py +8 -0
  250. uncountable/types/integration_server_t.py +46 -0
  251. uncountable/types/integration_session.py +10 -0
  252. uncountable/types/integration_session_t.py +60 -0
  253. uncountable/types/integrations.py +10 -0
  254. uncountable/types/integrations_t.py +62 -0
  255. uncountable/types/job_definition.py +4 -6
  256. uncountable/types/job_definition_t.py +96 -65
  257. uncountable/types/listing.py +9 -0
  258. uncountable/types/listing_t.py +51 -0
  259. uncountable/types/notices.py +8 -0
  260. uncountable/types/notices_t.py +37 -0
  261. uncountable/types/notifications.py +11 -0
  262. uncountable/types/notifications_t.py +74 -0
  263. uncountable/types/outputs.py +0 -1
  264. uncountable/types/outputs_t.py +6 -3
  265. uncountable/types/overrides.py +9 -0
  266. uncountable/types/overrides_t.py +49 -0
  267. uncountable/types/permissions.py +0 -1
  268. uncountable/types/permissions_t.py +1 -2
  269. uncountable/types/phases.py +0 -1
  270. uncountable/types/phases_t.py +5 -2
  271. uncountable/types/post_base.py +0 -1
  272. uncountable/types/post_base_t.py +1 -2
  273. uncountable/types/queued_job.py +17 -0
  274. uncountable/types/queued_job_t.py +140 -0
  275. uncountable/types/recipe_identifiers.py +0 -1
  276. uncountable/types/recipe_identifiers_t.py +21 -8
  277. uncountable/types/recipe_inputs.py +0 -1
  278. uncountable/types/recipe_inputs_t.py +1 -2
  279. uncountable/types/recipe_links.py +0 -1
  280. uncountable/types/recipe_links_t.py +7 -4
  281. uncountable/types/recipe_metadata.py +0 -1
  282. uncountable/types/recipe_metadata_t.py +14 -9
  283. uncountable/types/recipe_output_metadata.py +0 -1
  284. uncountable/types/recipe_output_metadata_t.py +5 -2
  285. uncountable/types/recipe_tags.py +0 -1
  286. uncountable/types/recipe_tags_t.py +5 -2
  287. uncountable/types/recipe_workflow_steps.py +0 -1
  288. uncountable/types/recipe_workflow_steps_t.py +14 -7
  289. uncountable/types/recipes.py +0 -1
  290. uncountable/types/recipes_t.py +6 -2
  291. uncountable/types/response.py +0 -1
  292. uncountable/types/response_t.py +3 -2
  293. uncountable/types/secret_retrieval.py +0 -1
  294. uncountable/types/secret_retrieval_t.py +13 -7
  295. uncountable/types/sockets.py +20 -0
  296. uncountable/types/sockets_t.py +169 -0
  297. uncountable/types/structured_filters.py +25 -0
  298. uncountable/types/structured_filters_t.py +248 -0
  299. uncountable/types/units.py +0 -1
  300. uncountable/types/units_t.py +5 -2
  301. uncountable/types/uploader.py +24 -0
  302. uncountable/types/uploader_t.py +222 -0
  303. uncountable/types/users.py +0 -1
  304. uncountable/types/users_t.py +5 -2
  305. uncountable/types/webhook_job.py +9 -0
  306. uncountable/types/webhook_job_t.py +48 -0
  307. uncountable/types/workflows.py +0 -1
  308. uncountable/types/workflows_t.py +10 -4
  309. uncountablepythonsdk-0.0.131.dist-info/METADATA +64 -0
  310. uncountablepythonsdk-0.0.131.dist-info/RECORD +363 -0
  311. {UncountablePythonSDK-0.0.52.dist-info → uncountablepythonsdk-0.0.131.dist-info}/WHEEL +1 -1
  312. UncountablePythonSDK-0.0.52.dist-info/METADATA +0 -56
  313. UncountablePythonSDK-0.0.52.dist-info/RECORD +0 -246
  314. docs/quickstart.md +0 -19
  315. uncountable/core/version.py +0 -11
  316. {UncountablePythonSDK-0.0.52.dist-info → uncountablepythonsdk-0.0.131.dist-info}/top_level.txt +0 -0
@@ -1,28 +1,38 @@
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
8
8
  from apscheduler.schedulers.background import BackgroundScheduler
9
9
  from apscheduler.schedulers.base import BaseScheduler
10
10
  from apscheduler.triggers.cron import CronTrigger
11
+ from opentelemetry.trace import get_current_span
11
12
  from sqlalchemy.engine.base import Engine
12
13
 
14
+ from uncountable.core.environment import get_local_admin_server_port
13
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
+ )
14
19
  from uncountable.integration.telemetry import Logger
15
- from uncountable.types import base_t
16
- from uncountable.types.client_config_t import ClientConfigOptions
20
+ from uncountable.types import base_t, job_definition_t
17
21
  from uncountable.types.job_definition_t import (
18
- AuthRetrieval,
19
22
  CronJobDefinition,
20
- JobDefinition,
21
- ProfileMetadata,
23
+ HttpJobDefinitionBase,
22
24
  )
23
25
 
24
26
  _MAX_APSCHEDULER_CONCURRENT_JOBS = 1
25
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
+
26
36
 
27
37
  class IntegrationServer:
28
38
  _scheduler: BaseScheduler
@@ -36,69 +46,83 @@ class IntegrationServer:
36
46
  jobstores={"default": SQLAlchemyJobStore(engine=engine)},
37
47
  executors={"default": ThreadPoolExecutor(_MAX_APSCHEDULER_CONCURRENT_JOBS)},
38
48
  )
39
- self._server_logger = Logger()
49
+ self._server_logger = Logger(get_current_span())
40
50
 
41
- def register_profile(
42
- self,
43
- *,
44
- profile_name: str,
45
- base_url: str,
46
- auth_retrieval: AuthRetrieval,
47
- jobs: list[JobDefinition],
48
- client_options: Optional[ClientConfigOptions],
49
- ) -> None:
50
- for job_defn in jobs:
51
- profile_metadata = ProfileMetadata(
52
- name=profile_name,
53
- auth_retrieval=auth_retrieval,
54
- base_url=base_url,
55
- client_options=client_options,
56
- )
57
- match job_defn:
58
- case CronJobDefinition():
59
- # Add to ap scheduler
60
- job_kwargs = asdict(
61
- CronJobArgs(
62
- definition=job_defn, profile_metadata=profile_metadata
63
- )
64
- )
65
- try:
66
- existing_job = self._scheduler.get_job(job_defn.id)
67
- except ValueError as e:
68
- self._server_logger.log_warning(
69
- f"could not reconstitute job {job_defn.id}: {e}"
70
- )
71
- self._scheduler.remove_job(job_defn.id)
72
- existing_job = None
73
- if existing_job is not None:
74
- existing_job.modify(
75
- name=job_defn.name,
76
- kwargs=job_kwargs,
77
- )
78
- existing_job.reschedule(
79
- CronTrigger.from_crontab(job_defn.cron_spec)
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
+
67
+ def register_jobs(self, profiles: list[job_definition_t.ProfileMetadata]) -> None:
68
+ valid_job_ids: set[str] = set()
69
+ for profile_metadata in profiles:
70
+ for job_defn in profile_metadata.jobs:
71
+ valid_job_ids.add(job_defn.id)
72
+ match job_defn:
73
+ case CronJobDefinition():
74
+ # Add to ap scheduler
75
+ job_kwargs = asdict(
76
+ CronJobArgs(
77
+ definition=job_defn, profile_metadata=profile_metadata
78
+ )
80
79
  )
81
- if not job_defn.enabled:
82
- existing_job.pause()
80
+ try:
81
+ existing_job = self._scheduler.get_job(job_defn.id)
82
+ except ValueError as e:
83
+ self._server_logger.log_warning(
84
+ f"could not reconstitute job {job_defn.id}: {e}"
85
+ )
86
+ self._scheduler.remove_job(job_defn.id)
87
+ existing_job = None
88
+ if existing_job is not None:
89
+ existing_job.modify(
90
+ name=job_defn.name,
91
+ kwargs=job_kwargs,
92
+ misfire_grace_time=None,
93
+ )
94
+ existing_job.reschedule(
95
+ CronTrigger.from_crontab(job_defn.cron_spec)
96
+ )
97
+ if not job_defn.enabled:
98
+ existing_job.pause()
99
+ else:
100
+ existing_job.resume()
83
101
  else:
84
- existing_job.resume()
85
- else:
86
- job_opts: dict[str, base_t.JsonValue] = {}
87
- if not job_defn.enabled:
88
- job_opts["next_run_time"] = None
89
- self._scheduler.add_job(
90
- cron_job_executor,
91
- # IMPROVE: reconsider these defaults
92
- max_instances=1,
93
- coalesce=True,
94
- trigger=CronTrigger.from_crontab(job_defn.cron_spec),
95
- name=job_defn.name,
96
- id=job_defn.id,
97
- kwargs=job_kwargs,
98
- **job_opts,
99
- )
100
- case _:
101
- assert_never(job_defn.trigger)
102
+ job_opts: dict[str, base_t.JsonValue] = {}
103
+ if not job_defn.enabled:
104
+ job_opts["next_run_time"] = None
105
+ self._scheduler.add_job(
106
+ cron_job_executor,
107
+ # IMPROVE: reconsider these defaults
108
+ max_instances=1,
109
+ coalesce=True,
110
+ trigger=CronTrigger.from_crontab(job_defn.cron_spec),
111
+ name=job_defn.name,
112
+ id=job_defn.id,
113
+ kwargs=job_kwargs,
114
+ misfire_grace_time=None,
115
+ **job_opts,
116
+ )
117
+ case HttpJobDefinitionBase():
118
+ pass
119
+ case _:
120
+ assert_never(job_defn)
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)
102
126
 
103
127
  def serve_forever(self) -> None:
104
128
  signal.pause()
@@ -111,12 +135,13 @@ class IntegrationServer:
111
135
 
112
136
  def __enter__(self) -> "IntegrationServer":
113
137
  self._start_apscheduler()
138
+ self._register_static_jobs()
114
139
  return self
115
140
 
116
141
  def __exit__(
117
142
  self,
118
- exc_type: Optional[type[BaseException]],
119
- exc_val: Optional[BaseException],
120
- exc_tb: Optional[TracebackType],
143
+ exc_type: type[BaseException] | None,
144
+ exc_val: BaseException | None,
145
+ exc_tb: TracebackType | None,
121
146
  ) -> None:
122
147
  self._stop_apscheduler()
@@ -1,22 +1,34 @@
1
1
  import functools
2
+ import json
2
3
  import os
3
- import sys
4
4
  import time
5
+ import traceback
6
+ import typing
5
7
  from contextlib import contextmanager
6
8
  from enum import StrEnum
7
- from typing import Generator, TextIO, assert_never, cast
9
+ from typing import Generator, assert_never, cast
8
10
 
9
- from opentelemetry import trace
11
+ from opentelemetry import _logs, trace
12
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
10
13
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
11
- from opentelemetry.sdk._logs import LogRecord
14
+ from opentelemetry.sdk._logs import Logger as OTELLogger
15
+ from opentelemetry.sdk._logs import (
16
+ LoggerProvider,
17
+ LogRecord,
18
+ )
19
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter
12
20
  from opentelemetry.sdk.resources import Attributes, Resource
13
21
  from opentelemetry.sdk.trace import TracerProvider
14
22
  from opentelemetry.sdk.trace.export import (
15
23
  SimpleSpanProcessor,
16
24
  )
17
- from opentelemetry.trace import Tracer
25
+ from opentelemetry.trace import DEFAULT_TRACE_OPTIONS, Span, Tracer
18
26
 
19
- from uncountable.core.version import get_version
27
+ from uncountable.core.environment import (
28
+ get_otel_enabled,
29
+ get_server_env,
30
+ get_version,
31
+ )
20
32
  from uncountable.types import base_t, job_definition_t
21
33
 
22
34
 
@@ -24,8 +36,13 @@ def _cast_attributes(attributes: dict[str, base_t.JsonValue]) -> Attributes:
24
36
  return cast(Attributes, attributes)
25
37
 
26
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
+
27
44
  @functools.cache
28
- def get_tracer() -> Tracer:
45
+ def get_otel_resource() -> Resource:
29
46
  attributes: dict[str, base_t.JsonValue] = {
30
47
  "service.name": "integration-server",
31
48
  "sdk.version": get_version(),
@@ -33,16 +50,34 @@ def get_tracer() -> Tracer:
33
50
  unc_version = os.environ.get("UNC_VERSION")
34
51
  if unc_version is not None:
35
52
  attributes["service.version"] = unc_version
36
- unc_env = os.environ.get("UNC_INTEGRATION_ENV")
53
+ unc_env = get_server_env()
37
54
  if unc_env is not None:
38
55
  attributes["deployment.environment"] = unc_env
39
56
  resource = Resource.create(attributes=_cast_attributes(attributes))
40
- provider = TracerProvider(resource=resource)
41
- provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter()))
57
+ return resource
58
+
59
+
60
+ @functools.cache
61
+ def get_otel_tracer() -> Tracer:
62
+ provider = TracerProvider(resource=get_otel_resource())
63
+ if get_otel_enabled():
64
+ provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter()))
42
65
  trace.set_tracer_provider(provider)
43
66
  return provider.get_tracer("integration.telemetry")
44
67
 
45
68
 
69
+ @functools.cache
70
+ def get_otel_logger() -> OTELLogger:
71
+ provider = LoggerProvider(resource=get_otel_resource())
72
+ provider.add_log_record_processor(
73
+ BatchLogRecordProcessor(ConsoleLogExporter(formatter=one_line_formatter))
74
+ )
75
+ if get_otel_enabled():
76
+ provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
77
+ _logs.set_logger_provider(provider)
78
+ return provider.get_logger("integration.telemetry")
79
+
80
+
46
81
  class LogSeverity(StrEnum):
47
82
  INFO = "Info"
48
83
  WARN = "Warn"
@@ -50,50 +85,129 @@ class LogSeverity(StrEnum):
50
85
 
51
86
 
52
87
  class Logger:
53
- current_span_id: int | None = None
54
- current_trace_id: int | None = None
88
+ current_span: Span
55
89
 
56
- def _patch_attributes(self, attributes: Attributes | None) -> Attributes:
57
- return attributes or {}
90
+ def __init__(self, base_span: Span) -> None:
91
+ self.current_span = base_span
92
+
93
+ @property
94
+ def current_span_id(self) -> int:
95
+ return self.current_span.get_span_context().span_id
96
+
97
+ @property
98
+ def current_trace_id(self) -> int | None:
99
+ return self.current_span.get_span_context().trace_id
100
+
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
58
122
 
59
123
  def _emit_log(
60
124
  self, message: str, *, severity: LogSeverity, attributes: Attributes | None
61
125
  ) -> None:
126
+ otel_logger = get_otel_logger()
62
127
  log_record = LogRecord(
63
128
  body=message,
64
129
  severity_text=severity,
65
130
  timestamp=time.time_ns(),
66
- attributes=self._patch_attributes(attributes),
131
+ attributes=self._patch_attributes(
132
+ message=message, severity=severity, attributes=attributes
133
+ ),
67
134
  span_id=self.current_span_id,
68
135
  trace_id=self.current_trace_id,
136
+ trace_flags=DEFAULT_TRACE_OPTIONS,
137
+ severity_number=_logs.SeverityNumber.UNSPECIFIED,
138
+ resource=get_otel_resource(),
69
139
  )
70
- log_file: TextIO = sys.stderr if severity == LogSeverity.ERROR else sys.stdout
71
- log_file.write(log_record.to_json())
72
- log_file.flush()
140
+ otel_logger.emit(log_record)
73
141
 
74
142
  def log_info(self, message: str, *, attributes: Attributes | None = None) -> None:
75
- self._emit_log(message=message, severity=LogSeverity.INFO, attributes=attributes)
143
+ self._emit_log(
144
+ message=message, severity=LogSeverity.INFO, attributes=attributes
145
+ )
76
146
 
77
- def log_warning(self, message: str, *, attributes: Attributes | None = None) -> None:
78
- self._emit_log(message=message, severity=LogSeverity.WARN, attributes=attributes)
147
+ def log_warning(
148
+ self, message: str, *, attributes: Attributes | None = None
149
+ ) -> None:
150
+ self._emit_log(
151
+ message=message, severity=LogSeverity.WARN, attributes=attributes
152
+ )
79
153
 
80
154
  def log_error(self, message: str, *, attributes: Attributes | None = None) -> None:
81
- self._emit_log(message=message, severity=LogSeverity.ERROR, attributes=attributes)
155
+ self._emit_log(
156
+ message=message, severity=LogSeverity.ERROR, attributes=attributes
157
+ )
158
+
159
+ def log_exception(
160
+ self,
161
+ exception: BaseException,
162
+ *,
163
+ message: str | None = None,
164
+ attributes: Attributes | None = None,
165
+ ) -> None:
166
+ traceback_str = "".join(traceback.format_exception(exception))
167
+ patched_attributes = self._patch_attributes(
168
+ message=message, severity=LogSeverity.ERROR, attributes=attributes
169
+ )
170
+ self.current_span.record_exception(
171
+ exception=exception, attributes=patched_attributes
172
+ )
173
+ self.log_error(
174
+ message=f"error: {message}\nexception: {exception}{traceback_str}",
175
+ attributes=patched_attributes,
176
+ )
177
+
178
+ @contextmanager
179
+ def push_scope(
180
+ self, scope_name: str, *, attributes: Attributes | None = None
181
+ ) -> Generator[typing.Self, None, None]:
182
+ with get_otel_tracer().start_as_current_span(
183
+ scope_name, attributes=self._patch_attributes(attributes)
184
+ ):
185
+ yield self
82
186
 
83
187
 
84
188
  class JobLogger(Logger):
85
189
  def __init__(
86
190
  self,
87
191
  *,
192
+ base_span: Span,
88
193
  profile_metadata: job_definition_t.ProfileMetadata,
89
194
  job_definition: job_definition_t.JobDefinition,
90
195
  ) -> None:
91
196
  self.profile_metadata = profile_metadata
92
197
  self.job_definition = job_definition
198
+ super().__init__(base_span)
93
199
 
94
- 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:
95
207
  patched_attributes: dict[str, base_t.JsonValue] = {
96
- **(attributes if attributes is not None else {})
208
+ **super()._patch_attributes(
209
+ attributes=attributes, message=message, severity=severity
210
+ )
97
211
  }
98
212
  patched_attributes["profile.name"] = self.profile_metadata.name
99
213
  patched_attributes["profile.base_url"] = self.profile_metadata.base_url
@@ -105,6 +219,8 @@ class JobLogger(Logger):
105
219
  patched_attributes["job.definition.cron_spec"] = (
106
220
  self.job_definition.cron_spec
107
221
  )
222
+ case job_definition_t.HttpJobDefinitionBase():
223
+ pass
108
224
  case _:
109
225
  assert_never(self.job_definition)
110
226
  patched_attributes["job.definition.executor.type"] = (
@@ -123,13 +239,13 @@ class JobLogger(Logger):
123
239
  assert_never(self.job_definition.executor)
124
240
  return _cast_attributes(patched_attributes)
125
241
 
126
- @contextmanager
127
- def push_scope(
128
- self, scope_name: str, *, attributes: Attributes | None = None
129
- ) -> Generator["JobLogger", None, None]:
130
- with get_tracer().start_as_current_span(
131
- scope_name, attributes=self._patch_attributes(attributes)
132
- ) as span:
133
- self.current_span_id = span.get_span_context().span_id
134
- self.current_trace_id = span.get_span_context().trace_id
135
- yield self
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
@@ -0,0 +1,97 @@
1
+ import base64
2
+
3
+ import flask
4
+ from flask.typing import ResponseReturnValue
5
+ from opentelemetry.trace import get_current_span
6
+ from uncountable.core.environment import (
7
+ get_http_server_port,
8
+ get_server_env,
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
13
+ from uncountable.integration.scan_profiles import load_profiles
14
+ from uncountable.integration.telemetry import Logger
15
+ from uncountable.types import job_definition_t
16
+
17
+ app = flask.Flask(__name__)
18
+
19
+
20
+ def register_route(
21
+ *,
22
+ server_logger: Logger,
23
+ profile_meta: job_definition_t.ProfileMetadata,
24
+ job: job_definition_t.HttpJobDefinitionBase,
25
+ ) -> None:
26
+ route = f"/{profile_meta.name}/{job.id}"
27
+
28
+ def handle_request() -> ResponseReturnValue:
29
+ with server_logger.push_scope(route):
30
+ try:
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
37
+ )
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),
45
+ )
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
51
+ )
52
+
53
+ return flask.make_response(
54
+ http_response.response,
55
+ http_response.status_code,
56
+ http_response.headers,
57
+ )
58
+ except HttpException as e:
59
+ server_logger.log_exception(e)
60
+ return e.make_error_response()
61
+ except Exception as e:
62
+ server_logger.log_exception(e)
63
+ return HttpException.unknown_error().make_error_response()
64
+
65
+ app.add_url_rule(
66
+ route,
67
+ endpoint=f"handle_request_{job.id}",
68
+ view_func=handle_request,
69
+ methods=["POST"],
70
+ )
71
+
72
+ server_logger.log_info(f"job {job.id} webhook registered at: {route}")
73
+
74
+
75
+ def main() -> None:
76
+ app.add_url_rule("/health", "health", lambda: ("OK", 200))
77
+
78
+ profiles = load_profiles()
79
+ for profile_metadata in profiles:
80
+ server_logger = Logger(get_current_span())
81
+ for job in profile_metadata.jobs:
82
+ if isinstance(job, job_definition_t.HttpJobDefinitionBase):
83
+ register_route(
84
+ server_logger=server_logger, profile_meta=profile_metadata, job=job
85
+ )
86
+
87
+
88
+ main()
89
+
90
+
91
+ if __name__ == "__main__":
92
+ app.run(
93
+ host="0.0.0.0",
94
+ port=get_http_server_port(),
95
+ debug=get_server_env() == "playground",
96
+ exclude_patterns=[],
97
+ )