UncountablePythonSDK 0.0.83__py3-none-any.whl → 0.0.132__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (298) hide show
  1. docs/conf.py +54 -7
  2. docs/index.md +107 -4
  3. docs/integration_examples/create_ingredient.md +43 -0
  4. docs/integration_examples/create_output.md +56 -0
  5. docs/integration_examples/index.md +6 -0
  6. docs/justfile +2 -2
  7. docs/requirements.txt +6 -4
  8. examples/basic_auth.py +7 -0
  9. examples/create_ingredient_sdk.py +34 -0
  10. examples/download_files.py +26 -0
  11. examples/integration-server/jobs/materials_auto/concurrent_cron.py +11 -0
  12. examples/integration-server/jobs/materials_auto/example_cron.py +3 -0
  13. examples/integration-server/jobs/materials_auto/example_http.py +47 -0
  14. examples/integration-server/jobs/materials_auto/example_instrument.py +100 -0
  15. examples/integration-server/jobs/materials_auto/example_parse.py +140 -0
  16. examples/integration-server/jobs/materials_auto/example_predictions.py +61 -0
  17. examples/integration-server/jobs/materials_auto/example_runsheet_wh.py +39 -0
  18. examples/integration-server/jobs/materials_auto/example_wh.py +17 -9
  19. examples/integration-server/jobs/materials_auto/profile.yaml +61 -0
  20. examples/integration-server/pyproject.toml +10 -10
  21. examples/oauth.py +7 -0
  22. examples/set_recipe_metadata_file.py +1 -1
  23. examples/upload_files.py +1 -2
  24. pkgs/argument_parser/__init__.py +8 -0
  25. pkgs/argument_parser/_is_namedtuple.py +3 -0
  26. pkgs/argument_parser/argument_parser.py +196 -63
  27. pkgs/filesystem_utils/__init__.py +1 -0
  28. pkgs/filesystem_utils/_blob_session.py +144 -0
  29. pkgs/filesystem_utils/_gdrive_session.py +5 -5
  30. pkgs/filesystem_utils/_s3_session.py +2 -1
  31. pkgs/filesystem_utils/_sftp_session.py +6 -3
  32. pkgs/filesystem_utils/file_type_utils.py +30 -10
  33. pkgs/serialization/__init__.py +7 -2
  34. pkgs/serialization/annotation.py +64 -0
  35. pkgs/serialization/missing_sentry.py +1 -1
  36. pkgs/serialization/opaque_key.py +1 -1
  37. pkgs/serialization/serial_alias.py +47 -0
  38. pkgs/serialization/serial_class.py +40 -48
  39. pkgs/serialization/serial_generic.py +16 -0
  40. pkgs/serialization/serial_union.py +16 -16
  41. pkgs/serialization_util/__init__.py +6 -0
  42. pkgs/serialization_util/dataclasses.py +14 -0
  43. pkgs/serialization_util/serialization_helpers.py +15 -5
  44. pkgs/type_spec/actions_registry/__main__.py +0 -4
  45. pkgs/type_spec/actions_registry/emit_typescript.py +2 -4
  46. pkgs/type_spec/builder.py +248 -70
  47. pkgs/type_spec/builder_types.py +9 -0
  48. pkgs/type_spec/config.py +40 -7
  49. pkgs/type_spec/cross_output_links.py +99 -0
  50. pkgs/type_spec/emit_open_api.py +121 -34
  51. pkgs/type_spec/emit_open_api_util.py +5 -5
  52. pkgs/type_spec/emit_python.py +277 -86
  53. pkgs/type_spec/emit_typescript.py +102 -29
  54. pkgs/type_spec/emit_typescript_util.py +66 -10
  55. pkgs/type_spec/load_types.py +16 -3
  56. pkgs/type_spec/non_discriminated_union_exceptions.py +14 -0
  57. pkgs/type_spec/open_api_util.py +29 -4
  58. pkgs/type_spec/parts/base.py.prepart +11 -8
  59. pkgs/type_spec/parts/base.ts.prepart +4 -0
  60. pkgs/type_spec/type_info/__main__.py +3 -1
  61. pkgs/type_spec/type_info/emit_type_info.py +115 -22
  62. pkgs/type_spec/ui_entry_actions/__init__.py +4 -0
  63. pkgs/type_spec/ui_entry_actions/generate_ui_entry_actions.py +308 -0
  64. pkgs/type_spec/util.py +3 -3
  65. pkgs/type_spec/value_spec/__main__.py +26 -9
  66. pkgs/type_spec/value_spec/convert_type.py +18 -0
  67. pkgs/type_spec/value_spec/emit_python.py +13 -3
  68. pkgs/type_spec/value_spec/types.py +1 -1
  69. uncountable/core/async_batch.py +1 -1
  70. uncountable/core/client.py +133 -34
  71. uncountable/core/environment.py +3 -3
  72. uncountable/core/file_upload.py +39 -15
  73. uncountable/integration/cli.py +116 -23
  74. uncountable/integration/construct_client.py +3 -3
  75. uncountable/integration/executors/executors.py +12 -2
  76. uncountable/integration/executors/generic_upload_executor.py +66 -14
  77. uncountable/integration/http_server/__init__.py +5 -0
  78. uncountable/integration/http_server/types.py +69 -0
  79. uncountable/integration/job.py +192 -7
  80. uncountable/integration/queue_runner/command_server/__init__.py +4 -0
  81. uncountable/integration/queue_runner/command_server/command_client.py +65 -0
  82. uncountable/integration/queue_runner/command_server/command_server.py +83 -5
  83. uncountable/integration/queue_runner/command_server/constants.py +4 -0
  84. uncountable/integration/queue_runner/command_server/protocol/command_server.proto +36 -0
  85. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py +28 -11
  86. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi +77 -1
  87. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2_grpc.py +135 -0
  88. uncountable/integration/queue_runner/command_server/types.py +25 -2
  89. uncountable/integration/queue_runner/datastore/datastore_sqlite.py +168 -11
  90. uncountable/integration/queue_runner/datastore/interface.py +10 -0
  91. uncountable/integration/queue_runner/datastore/model.py +8 -1
  92. uncountable/integration/queue_runner/job_scheduler.py +63 -23
  93. uncountable/integration/queue_runner/queue_runner.py +10 -2
  94. uncountable/integration/queue_runner/worker.py +3 -5
  95. uncountable/integration/scan_profiles.py +1 -1
  96. uncountable/integration/scheduler.py +74 -25
  97. uncountable/integration/secret_retrieval/retrieve_secret.py +1 -1
  98. uncountable/integration/server.py +42 -12
  99. uncountable/integration/telemetry.py +63 -10
  100. uncountable/integration/webhook_server/entrypoint.py +39 -112
  101. uncountable/types/__init__.py +58 -1
  102. uncountable/types/api/batch/execute_batch.py +5 -6
  103. uncountable/types/api/batch/execute_batch_load_async.py +2 -3
  104. uncountable/types/api/chemical/convert_chemical_formats.py +10 -5
  105. uncountable/types/api/condition_parameters/__init__.py +1 -0
  106. uncountable/types/api/condition_parameters/upsert_condition_match.py +72 -0
  107. uncountable/types/api/entity/create_entities.py +7 -7
  108. uncountable/types/api/entity/create_entity.py +8 -8
  109. uncountable/types/api/entity/create_or_update_entity.py +48 -0
  110. uncountable/types/api/entity/export_entities.py +59 -0
  111. uncountable/types/api/entity/get_entities_data.py +3 -4
  112. uncountable/types/api/entity/grant_entity_permissions.py +6 -6
  113. uncountable/types/api/entity/list_aggregate.py +79 -0
  114. uncountable/types/api/entity/list_entities.py +34 -10
  115. uncountable/types/api/entity/lock_entity.py +4 -4
  116. uncountable/types/api/entity/lookup_entity.py +116 -0
  117. uncountable/types/api/entity/resolve_entity_ids.py +5 -6
  118. uncountable/types/api/entity/set_entity_field_values.py +44 -0
  119. uncountable/types/api/entity/set_values.py +3 -3
  120. uncountable/types/api/entity/transition_entity_phase.py +14 -7
  121. uncountable/types/api/entity/unlock_entity.py +3 -3
  122. uncountable/types/api/equipment/associate_equipment_input.py +2 -3
  123. uncountable/types/api/field_options/upsert_field_options.py +7 -7
  124. uncountable/types/api/files/__init__.py +1 -0
  125. uncountable/types/api/files/download_file.py +77 -0
  126. uncountable/types/api/id_source/list_id_source.py +6 -7
  127. uncountable/types/api/id_source/match_id_source.py +4 -5
  128. uncountable/types/api/input_groups/get_input_group_names.py +3 -4
  129. uncountable/types/api/inputs/create_inputs.py +10 -9
  130. uncountable/types/api/inputs/get_input_data.py +11 -12
  131. uncountable/types/api/inputs/get_input_names.py +6 -7
  132. uncountable/types/api/inputs/get_inputs_data.py +6 -7
  133. uncountable/types/api/inputs/set_input_attribute_values.py +5 -6
  134. uncountable/types/api/inputs/set_input_category.py +5 -5
  135. uncountable/types/api/inputs/set_input_subcategories.py +3 -3
  136. uncountable/types/api/inputs/set_intermediate_type.py +4 -4
  137. uncountable/types/api/integrations/__init__.py +1 -0
  138. uncountable/types/api/integrations/publish_realtime_data.py +41 -0
  139. uncountable/types/api/integrations/push_notification.py +49 -0
  140. uncountable/types/api/integrations/register_sockets_token.py +41 -0
  141. uncountable/types/api/listing/__init__.py +1 -0
  142. uncountable/types/api/listing/fetch_listing.py +58 -0
  143. uncountable/types/api/material_families/update_entity_material_families.py +3 -4
  144. uncountable/types/api/notebooks/__init__.py +1 -0
  145. uncountable/types/api/notebooks/add_notebook_content.py +119 -0
  146. uncountable/types/api/outputs/get_output_data.py +12 -13
  147. uncountable/types/api/outputs/get_output_names.py +5 -6
  148. uncountable/types/api/outputs/get_output_organization.py +173 -0
  149. uncountable/types/api/outputs/resolve_output_conditions.py +7 -8
  150. uncountable/types/api/permissions/set_core_permissions.py +16 -10
  151. uncountable/types/api/project/get_projects.py +6 -7
  152. uncountable/types/api/project/get_projects_data.py +7 -8
  153. uncountable/types/api/recipe_links/create_recipe_link.py +5 -5
  154. uncountable/types/api/recipe_links/remove_recipe_link.py +4 -4
  155. uncountable/types/api/recipe_metadata/get_recipe_metadata_data.py +6 -7
  156. uncountable/types/api/recipes/add_recipe_to_project.py +3 -3
  157. uncountable/types/api/recipes/add_time_series_data.py +64 -0
  158. uncountable/types/api/recipes/archive_recipes.py +4 -4
  159. uncountable/types/api/recipes/associate_recipe_as_input.py +5 -5
  160. uncountable/types/api/recipes/associate_recipe_as_lot.py +3 -3
  161. uncountable/types/api/recipes/clear_recipe_outputs.py +3 -3
  162. uncountable/types/api/recipes/create_mix_order.py +44 -0
  163. uncountable/types/api/recipes/create_recipe.py +8 -9
  164. uncountable/types/api/recipes/create_recipes.py +8 -9
  165. uncountable/types/api/recipes/disassociate_recipe_as_input.py +3 -3
  166. uncountable/types/api/recipes/edit_recipe_inputs.py +101 -24
  167. uncountable/types/api/recipes/get_column_calculation_values.py +4 -5
  168. uncountable/types/api/recipes/get_curve.py +4 -5
  169. uncountable/types/api/recipes/get_recipe_calculations.py +6 -7
  170. uncountable/types/api/recipes/get_recipe_links.py +3 -4
  171. uncountable/types/api/recipes/get_recipe_names.py +3 -4
  172. uncountable/types/api/recipes/get_recipe_output_metadata.py +5 -6
  173. uncountable/types/api/recipes/get_recipes_data.py +62 -34
  174. uncountable/types/api/recipes/lock_recipes.py +9 -8
  175. uncountable/types/api/recipes/remove_recipe_from_project.py +3 -3
  176. uncountable/types/api/recipes/set_recipe_inputs.py +9 -10
  177. uncountable/types/api/recipes/set_recipe_metadata.py +3 -3
  178. uncountable/types/api/recipes/set_recipe_output_annotations.py +11 -12
  179. uncountable/types/api/recipes/set_recipe_output_file.py +5 -6
  180. uncountable/types/api/recipes/set_recipe_outputs.py +24 -13
  181. uncountable/types/api/recipes/set_recipe_tags.py +14 -9
  182. uncountable/types/api/recipes/set_recipe_total.py +59 -0
  183. uncountable/types/api/recipes/unarchive_recipes.py +3 -3
  184. uncountable/types/api/recipes/unlock_recipes.py +7 -6
  185. uncountable/types/api/runsheet/__init__.py +1 -0
  186. uncountable/types/api/runsheet/complete_async_upload.py +41 -0
  187. uncountable/types/api/triggers/run_trigger.py +4 -4
  188. uncountable/types/api/uploader/complete_async_parse.py +46 -0
  189. uncountable/types/api/uploader/invoke_uploader.py +4 -5
  190. uncountable/types/api/user/__init__.py +1 -0
  191. uncountable/types/api/user/get_current_user_info.py +40 -0
  192. uncountable/types/async_batch.py +1 -1
  193. uncountable/types/async_batch_processor.py +506 -23
  194. uncountable/types/async_batch_t.py +35 -8
  195. uncountable/types/async_jobs.py +0 -1
  196. uncountable/types/async_jobs_t.py +1 -2
  197. uncountable/types/auth_retrieval.py +0 -1
  198. uncountable/types/auth_retrieval_t.py +6 -6
  199. uncountable/types/base.py +0 -1
  200. uncountable/types/base_t.py +11 -9
  201. uncountable/types/calculations.py +0 -1
  202. uncountable/types/calculations_t.py +1 -2
  203. uncountable/types/chemical_structure.py +0 -1
  204. uncountable/types/chemical_structure_t.py +5 -5
  205. uncountable/types/client_base.py +614 -69
  206. uncountable/types/client_config.py +1 -1
  207. uncountable/types/client_config_t.py +13 -3
  208. uncountable/types/curves.py +0 -1
  209. uncountable/types/curves_t.py +6 -7
  210. uncountable/types/data.py +12 -0
  211. uncountable/types/data_t.py +103 -0
  212. uncountable/types/entity.py +1 -1
  213. uncountable/types/entity_t.py +90 -10
  214. uncountable/types/experiment_groups.py +0 -1
  215. uncountable/types/experiment_groups_t.py +1 -2
  216. uncountable/types/exports.py +8 -0
  217. uncountable/types/exports_t.py +34 -0
  218. uncountable/types/field_values.py +19 -1
  219. uncountable/types/field_values_t.py +242 -9
  220. uncountable/types/fields.py +0 -1
  221. uncountable/types/fields_t.py +1 -2
  222. uncountable/types/generic_upload.py +0 -1
  223. uncountable/types/generic_upload_t.py +14 -14
  224. uncountable/types/id_source.py +0 -1
  225. uncountable/types/id_source_t.py +13 -7
  226. uncountable/types/identifier.py +0 -1
  227. uncountable/types/identifier_t.py +10 -5
  228. uncountable/types/input_attributes.py +0 -1
  229. uncountable/types/input_attributes_t.py +3 -4
  230. uncountable/types/inputs.py +0 -1
  231. uncountable/types/inputs_t.py +3 -4
  232. uncountable/types/integration_server.py +0 -1
  233. uncountable/types/integration_server_t.py +13 -4
  234. uncountable/types/integration_session.py +10 -0
  235. uncountable/types/integration_session_t.py +60 -0
  236. uncountable/types/integrations.py +10 -0
  237. uncountable/types/integrations_t.py +62 -0
  238. uncountable/types/job_definition.py +2 -1
  239. uncountable/types/job_definition_t.py +57 -32
  240. uncountable/types/listing.py +9 -0
  241. uncountable/types/listing_t.py +51 -0
  242. uncountable/types/notices.py +8 -0
  243. uncountable/types/notices_t.py +37 -0
  244. uncountable/types/notifications.py +11 -0
  245. uncountable/types/notifications_t.py +74 -0
  246. uncountable/types/outputs.py +0 -1
  247. uncountable/types/outputs_t.py +2 -3
  248. uncountable/types/overrides.py +0 -1
  249. uncountable/types/overrides_t.py +10 -4
  250. uncountable/types/permissions.py +0 -1
  251. uncountable/types/permissions_t.py +1 -2
  252. uncountable/types/phases.py +0 -1
  253. uncountable/types/phases_t.py +1 -2
  254. uncountable/types/post_base.py +0 -1
  255. uncountable/types/post_base_t.py +1 -2
  256. uncountable/types/queued_job.py +2 -1
  257. uncountable/types/queued_job_t.py +29 -12
  258. uncountable/types/recipe_identifiers.py +0 -1
  259. uncountable/types/recipe_identifiers_t.py +18 -8
  260. uncountable/types/recipe_inputs.py +0 -1
  261. uncountable/types/recipe_inputs_t.py +1 -2
  262. uncountable/types/recipe_links.py +0 -1
  263. uncountable/types/recipe_links_t.py +3 -4
  264. uncountable/types/recipe_metadata.py +0 -1
  265. uncountable/types/recipe_metadata_t.py +9 -10
  266. uncountable/types/recipe_output_metadata.py +0 -1
  267. uncountable/types/recipe_output_metadata_t.py +1 -2
  268. uncountable/types/recipe_tags.py +0 -1
  269. uncountable/types/recipe_tags_t.py +1 -2
  270. uncountable/types/recipe_workflow_steps.py +0 -1
  271. uncountable/types/recipe_workflow_steps_t.py +7 -7
  272. uncountable/types/recipes.py +0 -1
  273. uncountable/types/recipes_t.py +2 -2
  274. uncountable/types/response.py +0 -1
  275. uncountable/types/response_t.py +2 -2
  276. uncountable/types/secret_retrieval.py +0 -1
  277. uncountable/types/secret_retrieval_t.py +7 -7
  278. uncountable/types/sockets.py +20 -0
  279. uncountable/types/sockets_t.py +169 -0
  280. uncountable/types/structured_filters.py +25 -0
  281. uncountable/types/structured_filters_t.py +248 -0
  282. uncountable/types/units.py +0 -1
  283. uncountable/types/units_t.py +1 -2
  284. uncountable/types/uploader.py +24 -0
  285. uncountable/types/uploader_t.py +222 -0
  286. uncountable/types/users.py +0 -1
  287. uncountable/types/users_t.py +1 -2
  288. uncountable/types/webhook_job.py +1 -1
  289. uncountable/types/webhook_job_t.py +14 -3
  290. uncountable/types/workflows.py +0 -1
  291. uncountable/types/workflows_t.py +3 -4
  292. uncountablepythonsdk-0.0.132.dist-info/METADATA +64 -0
  293. uncountablepythonsdk-0.0.132.dist-info/RECORD +363 -0
  294. {UncountablePythonSDK-0.0.83.dist-info → uncountablepythonsdk-0.0.132.dist-info}/WHEEL +1 -1
  295. UncountablePythonSDK-0.0.83.dist-info/METADATA +0 -60
  296. UncountablePythonSDK-0.0.83.dist-info/RECORD +0 -292
  297. docs/quickstart.md +0 -19
  298. {UncountablePythonSDK-0.0.83.dist-info → uncountablepythonsdk-0.0.132.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,8 @@
1
+ import datetime
1
2
  import uuid
2
- from datetime import datetime, timezone
3
+ from datetime import UTC
3
4
 
4
- from sqlalchemy import delete, insert, select, update
5
+ from sqlalchemy import delete, insert, or_, select, text, update
5
6
  from sqlalchemy.engine import Engine
6
7
 
7
8
  from pkgs.argument_parser import CachedParser
@@ -13,6 +14,8 @@ from uncountable.types import queued_job_t
13
14
 
14
15
  queued_job_payload_parser = CachedParser(queued_job_t.QueuedJobPayload)
15
16
 
17
+ MAX_QUEUE_WINDOW_DAYS = 30
18
+
16
19
 
17
20
  class DatastoreSqlite(Datastore):
18
21
  def __init__(self, session_maker: DBSessionMaker) -> None:
@@ -22,6 +25,17 @@ class DatastoreSqlite(Datastore):
22
25
  @classmethod
23
26
  def setup(cls, engine: Engine) -> None:
24
27
  Base.metadata.create_all(engine)
28
+ with engine.connect() as connection:
29
+ if not bool(
30
+ connection.execute(
31
+ text(
32
+ "select exists (select 1 from pragma_table_info('queued_jobs') where name='status');"
33
+ )
34
+ ).scalar()
35
+ ):
36
+ connection.execute(
37
+ text("alter table queued_jobs add column status VARCHAR")
38
+ )
25
39
 
26
40
  def add_job_to_queue(
27
41
  self, job_payload: queued_job_t.QueuedJobPayload, job_ref_name: str
@@ -30,11 +44,12 @@ class DatastoreSqlite(Datastore):
30
44
  serialized_payload = serialize_for_storage(job_payload)
31
45
  queued_job_uuid = str(uuid.uuid4())
32
46
  num_attempts = 0
33
- submitted_at = datetime.now(timezone.utc)
47
+ submitted_at = datetime.datetime.now(UTC)
34
48
  insert_stmt = insert(QueuedJob).values({
35
49
  QueuedJob.id.key: queued_job_uuid,
36
50
  QueuedJob.job_ref_name.key: job_ref_name,
37
51
  QueuedJob.payload.key: serialized_payload,
52
+ QueuedJob.status.key: queued_job_t.JobStatus.QUEUED,
38
53
  QueuedJob.num_attempts: num_attempts,
39
54
  QueuedJob.submitted_at: submitted_at,
40
55
  })
@@ -43,10 +58,48 @@ class DatastoreSqlite(Datastore):
43
58
  queued_job_uuid=queued_job_uuid,
44
59
  job_ref_name=job_ref_name,
45
60
  payload=job_payload,
61
+ status=queued_job_t.JobStatus.QUEUED,
46
62
  submitted_at=submitted_at,
47
63
  num_attempts=num_attempts,
48
64
  )
49
65
 
66
+ def retry_job(
67
+ self,
68
+ queued_job_uuid: str,
69
+ ) -> queued_job_t.QueuedJob | None:
70
+ with self.session_maker() as session:
71
+ select_stmt = select(
72
+ QueuedJob.id,
73
+ QueuedJob.payload,
74
+ QueuedJob.num_attempts,
75
+ QueuedJob.job_ref_name,
76
+ QueuedJob.status,
77
+ QueuedJob.submitted_at,
78
+ ).filter(QueuedJob.id == queued_job_uuid)
79
+ existing_job = session.execute(select_stmt).one_or_none()
80
+
81
+ if (
82
+ existing_job is None
83
+ or existing_job.status != queued_job_t.JobStatus.FAILED
84
+ ):
85
+ return None
86
+
87
+ update_stmt = (
88
+ update(QueuedJob)
89
+ .values({QueuedJob.status.key: queued_job_t.JobStatus.QUEUED})
90
+ .filter(QueuedJob.id == queued_job_uuid)
91
+ )
92
+ session.execute(update_stmt)
93
+
94
+ return queued_job_t.QueuedJob(
95
+ queued_job_uuid=existing_job.id,
96
+ job_ref_name=existing_job.job_ref_name,
97
+ num_attempts=existing_job.num_attempts,
98
+ status=queued_job_t.JobStatus.QUEUED,
99
+ submitted_at=existing_job.submitted_at,
100
+ payload=queued_job_payload_parser.parse_storage(existing_job.payload),
101
+ )
102
+
50
103
  def increment_num_attempts(self, queued_job_uuid: str) -> int:
51
104
  with self.session_maker() as session:
52
105
  update_stmt = (
@@ -56,7 +109,7 @@ class DatastoreSqlite(Datastore):
56
109
  )
57
110
  session.execute(update_stmt)
58
111
  session.flush()
59
- # IMPROVE: python3.12's sqlite does not support the RETURNING clause
112
+ # IMPROVE: python3's sqlite does not support the RETURNING clause
60
113
  select_stmt = select(QueuedJob.num_attempts).filter(
61
114
  QueuedJob.id == queued_job_uuid
62
115
  )
@@ -67,15 +120,103 @@ class DatastoreSqlite(Datastore):
67
120
  delete_stmt = delete(QueuedJob).filter(QueuedJob.id == queued_job_uuid)
68
121
  session.execute(delete_stmt)
69
122
 
123
+ def update_job_status(
124
+ self, queued_job_uuid: str, status: queued_job_t.JobStatus
125
+ ) -> None:
126
+ with self.session_maker() as session:
127
+ update_stmt = (
128
+ update(QueuedJob)
129
+ .values({QueuedJob.status.key: status})
130
+ .filter(QueuedJob.id == queued_job_uuid)
131
+ )
132
+ session.execute(update_stmt)
133
+
134
+ def list_queued_job_metadata(
135
+ self, offset: int = 0, limit: int | None = 100
136
+ ) -> list[queued_job_t.QueuedJobMetadata]:
137
+ with self.session_maker() as session:
138
+ select_statement = (
139
+ select(
140
+ QueuedJob.id,
141
+ QueuedJob.job_ref_name,
142
+ QueuedJob.num_attempts,
143
+ QueuedJob.status,
144
+ QueuedJob.submitted_at,
145
+ )
146
+ .order_by(QueuedJob.submitted_at)
147
+ .offset(offset)
148
+ .limit(limit)
149
+ )
150
+
151
+ queued_job_metadata: list[queued_job_t.QueuedJobMetadata] = [
152
+ queued_job_t.QueuedJobMetadata(
153
+ queued_job_uuid=row.id,
154
+ job_ref_name=row.job_ref_name,
155
+ num_attempts=row.num_attempts,
156
+ status=row.status or queued_job_t.JobStatus.QUEUED,
157
+ submitted_at=row.submitted_at,
158
+ )
159
+ for row in session.execute(select_statement)
160
+ ]
161
+
162
+ return queued_job_metadata
163
+
164
+ def get_next_queued_job_for_ref_name(
165
+ self, job_ref_name: str
166
+ ) -> queued_job_t.QueuedJob | None:
167
+ with self.session_maker() as session:
168
+ select_stmt = (
169
+ select(
170
+ QueuedJob.id,
171
+ QueuedJob.payload,
172
+ QueuedJob.num_attempts,
173
+ QueuedJob.job_ref_name,
174
+ QueuedJob.status,
175
+ QueuedJob.submitted_at,
176
+ )
177
+ .filter(QueuedJob.job_ref_name == job_ref_name)
178
+ .filter(
179
+ or_(
180
+ QueuedJob.status == queued_job_t.JobStatus.QUEUED,
181
+ QueuedJob.status.is_(None),
182
+ )
183
+ )
184
+ .limit(1)
185
+ .order_by(QueuedJob.submitted_at)
186
+ )
187
+
188
+ for row in session.execute(select_stmt):
189
+ parsed_payload = queued_job_payload_parser.parse_storage(row.payload)
190
+ return queued_job_t.QueuedJob(
191
+ queued_job_uuid=row.id,
192
+ job_ref_name=row.job_ref_name,
193
+ num_attempts=row.num_attempts,
194
+ status=row.status or queued_job_t.JobStatus.QUEUED,
195
+ submitted_at=row.submitted_at,
196
+ payload=parsed_payload,
197
+ )
198
+
199
+ return None
200
+
70
201
  def load_job_queue(self) -> list[queued_job_t.QueuedJob]:
71
202
  with self.session_maker() as session:
72
- select_stmt = select(
73
- QueuedJob.id,
74
- QueuedJob.payload,
75
- QueuedJob.num_attempts,
76
- QueuedJob.job_ref_name,
77
- QueuedJob.submitted_at,
78
- ).order_by(QueuedJob.submitted_at)
203
+ select_stmt = (
204
+ select(
205
+ QueuedJob.id,
206
+ QueuedJob.payload,
207
+ QueuedJob.num_attempts,
208
+ QueuedJob.job_ref_name,
209
+ QueuedJob.status,
210
+ QueuedJob.submitted_at,
211
+ )
212
+ .filter(
213
+ or_(
214
+ QueuedJob.status == queued_job_t.JobStatus.QUEUED,
215
+ QueuedJob.status.is_(None),
216
+ )
217
+ )
218
+ .order_by(QueuedJob.submitted_at)
219
+ )
79
220
 
80
221
  queued_jobs: list[queued_job_t.QueuedJob] = []
81
222
  for row in session.execute(select_stmt):
@@ -85,9 +226,25 @@ class DatastoreSqlite(Datastore):
85
226
  queued_job_uuid=row.id,
86
227
  job_ref_name=row.job_ref_name,
87
228
  num_attempts=row.num_attempts,
229
+ status=row.status or queued_job_t.JobStatus.QUEUED,
88
230
  submitted_at=row.submitted_at,
89
231
  payload=parsed_payload,
90
232
  )
91
233
  )
92
234
 
93
235
  return queued_jobs
236
+
237
+ def vaccuum_queued_jobs(self) -> None:
238
+ with self.session_maker() as session:
239
+ delete_stmt = (
240
+ delete(QueuedJob)
241
+ .filter(QueuedJob.status == queued_job_t.JobStatus.QUEUED)
242
+ .filter(
243
+ QueuedJob.submitted_at
244
+ <= (
245
+ datetime.datetime.now(UTC)
246
+ - datetime.timedelta(days=MAX_QUEUE_WINDOW_DAYS)
247
+ )
248
+ )
249
+ )
250
+ session.execute(delete_stmt)
@@ -17,3 +17,13 @@ class Datastore(ABC):
17
17
 
18
18
  @abstractmethod
19
19
  def load_job_queue(self) -> list[queued_job_t.QueuedJob]: ...
20
+
21
+ @abstractmethod
22
+ def get_next_queued_job_for_ref_name(
23
+ self, job_ref_name: str
24
+ ) -> queued_job_t.QueuedJob | None: ...
25
+
26
+ @abstractmethod
27
+ def list_queued_job_metadata(
28
+ self, offset: int, limit: int | None
29
+ ) -> list[queued_job_t.QueuedJobMetadata]: ...
@@ -1,7 +1,9 @@
1
- from sqlalchemy import JSON, BigInteger, Column, DateTime, Text
1
+ from sqlalchemy import JSON, BigInteger, Column, DateTime, Enum, Text
2
2
  from sqlalchemy.orm import declarative_base
3
3
  from sqlalchemy.sql import func
4
4
 
5
+ from uncountable.types import queued_job_t
6
+
5
7
  Base = declarative_base()
6
8
 
7
9
 
@@ -15,3 +17,8 @@ class QueuedJob(Base):
15
17
  )
16
18
  payload = Column(JSON, nullable=False)
17
19
  num_attempts = Column(BigInteger, nullable=False, default=0, server_default="0")
20
+ status = Column(
21
+ Enum(queued_job_t.JobStatus, length=None),
22
+ default=queued_job_t.JobStatus.QUEUED,
23
+ nullable=True,
24
+ )
@@ -1,18 +1,22 @@
1
1
  import asyncio
2
+ import sys
2
3
  import typing
3
4
  from concurrent.futures import ProcessPoolExecutor
4
5
  from dataclasses import dataclass
5
6
 
6
7
  from opentelemetry.trace import get_current_span
7
8
 
8
- from uncountable.integration.db.connect import IntegrationDBService, create_db_engine
9
- from uncountable.integration.db.session import get_session_maker
10
9
  from uncountable.integration.queue_runner.command_server import (
11
10
  CommandEnqueueJob,
12
11
  CommandEnqueueJobResponse,
13
12
  CommandQueue,
13
+ CommandRetryJob,
14
+ CommandRetryJobResponse,
14
15
  CommandTask,
15
16
  )
17
+ from uncountable.integration.queue_runner.command_server.types import (
18
+ CommandVaccuumQueuedJobs,
19
+ )
16
20
  from uncountable.integration.queue_runner.datastore import DatastoreSqlite
17
21
  from uncountable.integration.queue_runner.datastore.interface import Datastore
18
22
  from uncountable.integration.queue_runner.worker import Worker
@@ -34,6 +38,10 @@ class JobListenerKey:
34
38
  def _get_job_worker_key(
35
39
  job_definition: job_definition_t.JobDefinition, profile_name: str
36
40
  ) -> JobListenerKey:
41
+ if job_definition.subqueue_name is not None:
42
+ return JobListenerKey(
43
+ profile_name=profile_name, subqueue_name=job_definition.subqueue_name
44
+ )
37
45
  return JobListenerKey(profile_name=profile_name)
38
46
 
39
47
 
@@ -41,9 +49,12 @@ def on_worker_crash(
41
49
  worker_key: JobListenerKey,
42
50
  ) -> typing.Callable[[asyncio.Task], None]:
43
51
  def hook(task: asyncio.Task) -> None:
44
- raise Exception(
45
- f"worker {worker_key.profile_name}_{worker_key.subqueue_name} crashed unexpectedly"
52
+ Logger(get_current_span()).log_exception(
53
+ Exception(
54
+ f"worker {worker_key.profile_name}_{worker_key.subqueue_name} crashed unexpectedly"
55
+ )
46
56
  )
57
+ sys.exit(1)
47
58
 
48
59
  return hook
49
60
 
@@ -75,14 +86,11 @@ def _start_workers(
75
86
  return job_worker_lookup
76
87
 
77
88
 
78
- async def start_scheduler(command_queue: CommandQueue) -> None:
89
+ async def start_scheduler(
90
+ command_queue: CommandQueue, datastore: DatastoreSqlite
91
+ ) -> None:
79
92
  logger = Logger(get_current_span())
80
93
  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
94
 
87
95
  with ProcessPoolExecutor(max_workers=_MAX_JOB_WORKERS) as process_pool:
88
96
  job_worker_lookup = _start_workers(
@@ -96,7 +104,9 @@ async def start_scheduler(command_queue: CommandQueue) -> None:
96
104
  worker = job_worker_lookup[queued_job.job_ref_name]
97
105
  except KeyError as e:
98
106
  logger.log_exception(e)
99
- datastore.remove_job_from_queue(queued_job.queued_job_uuid)
107
+ datastore.update_job_status(
108
+ queued_job.queued_job_uuid, queued_job_t.JobStatus.FAILED
109
+ )
100
110
  return
101
111
  await worker.listen_queue.put(queued_job)
102
112
 
@@ -106,19 +116,16 @@ async def start_scheduler(command_queue: CommandQueue) -> None:
106
116
  ) -> str:
107
117
  if isinstance(
108
118
  payload.invocation_context,
109
- queued_job_t.InvocationContextCron,
119
+ (
120
+ queued_job_t.InvocationContextCron,
121
+ queued_job_t.InvocationContextManual,
122
+ ),
110
123
  ):
111
- existing_queued_jobs = datastore.load_job_queue()
112
- duplicate_job = next(
113
- (
114
- job
115
- for job in existing_queued_jobs
116
- if job.job_ref_name == job_ref_name
117
- ),
118
- None,
124
+ existing_queued_job = datastore.get_next_queued_job_for_ref_name(
125
+ job_ref_name=job_ref_name
119
126
  )
120
- if duplicate_job is not None:
121
- return duplicate_job.queued_job_uuid
127
+ if existing_queued_job is not None:
128
+ return existing_queued_job.queued_job_uuid
122
129
  queued_job = datastore.add_job_to_queue(
123
130
  job_payload=payload,
124
131
  job_ref_name=job_ref_name,
@@ -135,6 +142,25 @@ async def start_scheduler(command_queue: CommandQueue) -> None:
135
142
  CommandEnqueueJobResponse(queued_job_uuid=queued_job_uuid)
136
143
  )
137
144
 
145
+ async def _handle_retry_job_command(command: CommandRetryJob) -> None:
146
+ queued_job = datastore.retry_job(command.queued_job_uuid)
147
+ if queued_job is None:
148
+ await command.response_queue.put(
149
+ CommandRetryJobResponse(queued_job_uuid=None)
150
+ )
151
+ return
152
+
153
+ await enqueue_queued_job(queued_job)
154
+ await command.response_queue.put(
155
+ CommandRetryJobResponse(queued_job_uuid=queued_job.queued_job_uuid)
156
+ )
157
+
158
+ def _handle_vaccuum_queued_jobs_command(
159
+ command: CommandVaccuumQueuedJobs,
160
+ ) -> None:
161
+ logger.log_info("Vaccuuming queued jobs...")
162
+ datastore.vaccuum_queued_jobs()
163
+
138
164
  for queued_job in queued_jobs:
139
165
  await enqueue_queued_job(queued_job)
140
166
 
@@ -151,10 +177,24 @@ async def start_scheduler(command_queue: CommandQueue) -> None:
151
177
  match command:
152
178
  case CommandEnqueueJob():
153
179
  await _handle_enqueue_job_command(command=command)
180
+ case CommandRetryJob():
181
+ await _handle_retry_job_command(command=command)
182
+ case CommandVaccuumQueuedJobs():
183
+ _handle_vaccuum_queued_jobs_command(command=command)
154
184
  case _:
155
185
  typing.assert_never(command)
156
186
  command_task = asyncio.create_task(command_queue.get())
157
187
  elif task == result_task:
158
188
  queued_job_result = result_task.result()
159
- datastore.remove_job_from_queue(queued_job_result.queued_job_uuid)
189
+ match queued_job_result.job_result.success:
190
+ case True:
191
+ datastore.update_job_status(
192
+ queued_job_result.queued_job_uuid,
193
+ queued_job_t.JobStatus.SUCCESS,
194
+ )
195
+ case False:
196
+ datastore.update_job_status(
197
+ queued_job_result.queued_job_uuid,
198
+ queued_job_t.JobStatus.FAILED,
199
+ )
160
200
  result_task = asyncio.create_task(result_queue.get())
@@ -1,16 +1,24 @@
1
1
  import asyncio
2
2
 
3
+ from uncountable.integration.db.connect import IntegrationDBService, create_db_engine
4
+ from uncountable.integration.db.session import get_session_maker
3
5
  from uncountable.integration.queue_runner.command_server import serve
4
6
  from uncountable.integration.queue_runner.command_server.types import CommandQueue
7
+ from uncountable.integration.queue_runner.datastore import DatastoreSqlite
5
8
  from uncountable.integration.queue_runner.job_scheduler import start_scheduler
6
9
 
7
10
 
8
11
  async def queue_runner_loop() -> None:
9
12
  command_queue: CommandQueue = asyncio.Queue()
13
+ engine = create_db_engine(IntegrationDBService.RUNNER)
14
+ session_maker = get_session_maker(engine)
10
15
 
11
- command_server = asyncio.create_task(serve(command_queue))
16
+ datastore = DatastoreSqlite(session_maker)
17
+ datastore.setup(engine)
12
18
 
13
- scheduler = asyncio.create_task(start_scheduler(command_queue))
19
+ command_server = asyncio.create_task(serve(command_queue, datastore))
20
+
21
+ scheduler = asyncio.create_task(start_scheduler(command_queue, datastore))
14
22
 
15
23
  await scheduler
16
24
  await command_server
@@ -90,7 +90,7 @@ def run_queued_job(
90
90
  )
91
91
  try:
92
92
  client = construct_uncountable_client(
93
- profile_meta=job_details.profile_metadata, job_logger=job_logger
93
+ profile_meta=job_details.profile_metadata, logger=job_logger
94
94
  )
95
95
  batch_processor = AsyncBatchProcessor(client=client)
96
96
 
@@ -103,6 +103,7 @@ def run_queued_job(
103
103
  profile_metadata=job_details.profile_metadata,
104
104
  logger=job_logger,
105
105
  payload=payload,
106
+ job_uuid=queued_job.queued_job_uuid,
106
107
  )
107
108
 
108
109
  return execute_job(
@@ -110,9 +111,6 @@ def run_queued_job(
110
111
  profile_metadata=job_details.profile_metadata,
111
112
  job_definition=job_details.job_definition,
112
113
  )
113
- except Exception as e:
114
- job_logger.log_exception(e)
115
- return job_definition_t.JobResult(success=False)
116
114
  except BaseException as e:
117
115
  job_logger.log_exception(e)
118
- raise e
116
+ return job_definition_t.JobResult(success=False)
@@ -26,7 +26,7 @@ def load_profiles() -> list[job_definition_t.ProfileMetadata]:
26
26
  profile_name = profile_file.name
27
27
  try:
28
28
  definition = profile_parser.parse_yaml_resource(
29
- package=".".join([profiles_module, profile_name]),
29
+ package=f"{profiles_module}.{profile_name}",
30
30
  resource="profile.yaml",
31
31
  )
32
32
  for job in definition.jobs:
@@ -1,9 +1,11 @@
1
+ import datetime
1
2
  import multiprocessing
2
3
  import subprocess
3
4
  import sys
4
5
  import time
5
6
  from dataclasses import dataclass
6
- from datetime import datetime, timezone
7
+ from datetime import UTC
8
+ from enum import StrEnum
7
9
 
8
10
  from opentelemetry.trace import get_current_span
9
11
 
@@ -18,11 +20,19 @@ from uncountable.integration.telemetry import Logger
18
20
 
19
21
  SHUTDOWN_TIMEOUT_SECS = 30
20
22
 
23
+ AnyProcess = multiprocessing.Process | subprocess.Popen[bytes]
24
+
25
+
26
+ class ProcessName(StrEnum):
27
+ QUEUE_RUNNER = "queue_runner"
28
+ CRON_SERVER = "cron_server"
29
+ UWSGI = "uwsgi"
30
+
21
31
 
22
32
  @dataclass(kw_only=True)
23
33
  class ProcessInfo:
24
- name: str
25
- process: multiprocessing.Process | subprocess.Popen[bytes]
34
+ name: ProcessName
35
+ process: AnyProcess
26
36
 
27
37
  @property
28
38
  def is_alive(self) -> bool:
@@ -45,14 +55,14 @@ class ProcessInfo:
45
55
  return self.process.poll()
46
56
 
47
57
 
48
- def handle_shutdown(logger: Logger, processes: list[ProcessInfo]) -> None:
58
+ def handle_shutdown(logger: Logger, processes: dict[ProcessName, ProcessInfo]) -> None:
49
59
  logger.log_info("received shutdown command, shutting down sub-processes")
50
- for proc_info in processes:
60
+ for proc_info in processes.values():
51
61
  if proc_info.is_alive:
52
62
  proc_info.process.terminate()
53
63
 
54
64
  shutdown_start = time.time()
55
- still_living_processes = processes
65
+ still_living_processes = list(processes.values())
56
66
  while (
57
67
  time.time() - shutdown_start < SHUTDOWN_TIMEOUT_SECS
58
68
  and len(still_living_processes) > 0
@@ -81,46 +91,84 @@ def handle_shutdown(logger: Logger, processes: list[ProcessInfo]) -> None:
81
91
  proc_info.process.kill()
82
92
 
83
93
 
84
- def check_process_alive(logger: Logger, processes: list[ProcessInfo]) -> None:
85
- for proc_info in processes:
94
+ def restart_process(
95
+ logger: Logger, proc_info: ProcessInfo, processes: dict[ProcessName, ProcessInfo]
96
+ ) -> None:
97
+ logger.log_error(
98
+ f"process {proc_info.name} shut down unexpectedly - exit code {proc_info.exitcode}. Restarting..."
99
+ )
100
+
101
+ match proc_info.name:
102
+ case ProcessName.QUEUE_RUNNER:
103
+ queue_proc = multiprocessing.Process(target=start_queue_runner)
104
+ queue_proc.start()
105
+ new_info = ProcessInfo(name=ProcessName.QUEUE_RUNNER, process=queue_proc)
106
+ processes[ProcessName.QUEUE_RUNNER] = new_info
107
+ try:
108
+ _wait_queue_runner_online()
109
+ logger.log_info("queue runner restarted successfully")
110
+ except Exception as e:
111
+ logger.log_exception(e)
112
+ logger.log_error(
113
+ "queue runner failed to restart, shutting down scheduler"
114
+ )
115
+ handle_shutdown(logger, processes)
116
+ sys.exit(1)
117
+
118
+ case ProcessName.CRON_SERVER:
119
+ cron_proc = multiprocessing.Process(target=cron_target)
120
+ cron_proc.start()
121
+ new_info = ProcessInfo(name=ProcessName.CRON_SERVER, process=cron_proc)
122
+ processes[ProcessName.CRON_SERVER] = new_info
123
+ logger.log_info("cron server restarted successfully")
124
+
125
+ case ProcessName.UWSGI:
126
+ uwsgi_proc: AnyProcess = subprocess.Popen(["uwsgi", "--die-on-term"])
127
+ new_info = ProcessInfo(name=ProcessName.UWSGI, process=uwsgi_proc)
128
+ processes[ProcessName.UWSGI] = new_info
129
+ logger.log_info("uwsgi restarted successfully")
130
+
131
+
132
+ def check_process_alive(
133
+ logger: Logger, processes: dict[ProcessName, ProcessInfo]
134
+ ) -> None:
135
+ for proc_info in processes.values():
86
136
  if not proc_info.is_alive:
87
- logger.log_error(
88
- f"process {proc_info.name} shut down unexpectedly! shutting down scheduler; exit code is {proc_info.exitcode}"
89
- )
90
- handle_shutdown(logger, processes)
91
- sys.exit(1)
137
+ restart_process(logger, proc_info, processes)
92
138
 
93
139
 
94
140
  def _wait_queue_runner_online() -> None:
95
- _MAX_QUEUE_RUNNER_HEALTH_CHECKS = 10
96
- _QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS = 1
141
+ MAX_QUEUE_RUNNER_HEALTH_CHECKS = 10
142
+ QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS = 1
97
143
 
98
144
  num_attempts = 0
99
- before = datetime.now(timezone.utc)
100
- while num_attempts < _MAX_QUEUE_RUNNER_HEALTH_CHECKS:
145
+ before = datetime.datetime.now(UTC)
146
+ while num_attempts < MAX_QUEUE_RUNNER_HEALTH_CHECKS:
101
147
  try:
102
148
  if check_health(port=get_local_admin_server_port()):
103
149
  return
104
150
  except CommandServerTimeout:
105
151
  pass
106
152
  num_attempts += 1
107
- time.sleep(_QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS)
108
- after = datetime.now(timezone.utc)
153
+ time.sleep(QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS)
154
+ after = datetime.datetime.now(UTC)
109
155
  duration_secs = (after - before).seconds
110
156
  raise Exception(f"queue runner failed to come online after {duration_secs} seconds")
111
157
 
112
158
 
113
159
  def main() -> None:
114
160
  logger = Logger(get_current_span())
115
- processes: list[ProcessInfo] = []
161
+ processes: dict[ProcessName, ProcessInfo] = {}
162
+
163
+ multiprocessing.set_start_method("forkserver")
116
164
 
117
165
  def add_process(process: ProcessInfo) -> None:
118
- processes.append(process)
166
+ processes[process.name] = process
119
167
  logger.log_info(f"started process {process.name}")
120
168
 
121
169
  runner_process = multiprocessing.Process(target=start_queue_runner)
122
170
  runner_process.start()
123
- add_process(ProcessInfo(name="queue runner", process=runner_process))
171
+ add_process(ProcessInfo(name=ProcessName.QUEUE_RUNNER, process=runner_process))
124
172
 
125
173
  try:
126
174
  _wait_queue_runner_online()
@@ -131,13 +179,13 @@ def main() -> None:
131
179
 
132
180
  cron_process = multiprocessing.Process(target=cron_target)
133
181
  cron_process.start()
134
- add_process(ProcessInfo(name="cron server", process=cron_process))
182
+ add_process(ProcessInfo(name=ProcessName.CRON_SERVER, process=cron_process))
135
183
 
136
184
  uwsgi_process = subprocess.Popen([
137
185
  "uwsgi",
138
186
  "--die-on-term",
139
187
  ])
140
- add_process(ProcessInfo(name="uwsgi", process=uwsgi_process))
188
+ add_process(ProcessInfo(name=ProcessName.UWSGI, process=uwsgi_process))
141
189
 
142
190
  try:
143
191
  while True:
@@ -147,4 +195,5 @@ def main() -> None:
147
195
  handle_shutdown(logger, processes=processes)
148
196
 
149
197
 
150
- main()
198
+ if __name__ == "__main__":
199
+ main()
@@ -54,7 +54,7 @@ def _load_secret_overrides(profile_name: str) -> dict[SecretRetrieval, str]:
54
54
  profiles_module = os.environ["UNC_PROFILES_MODULE"]
55
55
  try:
56
56
  overrides = overrides_parser.parse_yaml_resource(
57
- package=".".join([profiles_module, profile_name]),
57
+ package=f"{profiles_module}.{profile_name}",
58
58
  resource="local_overrides.yaml",
59
59
  )
60
60
  return {