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
@@ -31,6 +31,11 @@ TYPE_MAP = {
31
31
  # not part of type_spec's types now
32
32
  "Symbol": MappedType(base_type=value_spec_t.BaseType.SYMBOL),
33
33
  "Any": MappedType(base_type=value_spec_t.BaseType.ANY),
34
+ "None": MappedType(base_type=value_spec_t.BaseType.NONE),
35
+ "Tuple": MappedType(
36
+ base_type=value_spec_t.BaseType.TUPLE, variable_param_count=True
37
+ ),
38
+ "Never": MappedType(base_type=value_spec_t.BaseType.NEVER),
34
39
  }
35
40
 
36
41
 
@@ -56,3 +61,16 @@ def convert_to_value_spec_type(parsed: ParsedTypePath) -> value_spec_t.ValueType
56
61
  return value_spec_t.ValueType(base_type=mapped.base_type, parameters=parameters)
57
62
 
58
63
  # Our formatter was duplicating the previous line for an unknown reason, this comment blocks that
64
+
65
+
66
+ def convert_from_value_spec_type(
67
+ base_type: value_spec_t.BaseType,
68
+ ) -> str:
69
+ for type_spec_type, mapped_type in TYPE_MAP.items():
70
+ if (
71
+ mapped_type.base_type == base_type
72
+ and mapped_type.param_count == 0
73
+ and mapped_type.variable_param_count is False
74
+ ):
75
+ return type_spec_type
76
+ raise ValueError(f"invalid value spec type {base_type}")
@@ -70,14 +70,19 @@ def _emit_function_wrapper(function: value_spec_t.Function) -> str:
70
70
  else:
71
71
  python_type = _emit_python_type(argument.type)
72
72
  if (
73
- argument.pass_null
73
+ argument.on_null == value_spec_t.OnNull.PASS
74
74
  or argument.extant == value_spec_t.ArgumentExtant.MISSING
75
75
  ):
76
76
  python_type += " | None"
77
77
  any_pass_null = True
78
+
79
+ if python_type.startswith("base_t.ExtJsonValue"):
80
+ return_statement = f"self._extract({index})"
81
+ else:
82
+ return_statement = f"cast({python_type}, self._extract({index}))"
78
83
  out.write(
79
84
  f"""{INDENT}def get_{argument.ref_name}(self) -> {python_type}:
80
- {INDENT}{INDENT}return cast({python_type}, self._extract({index}))
85
+ {INDENT}{INDENT}return {return_statement}
81
86
  """
82
87
  )
83
88
  out.write("\n")
@@ -164,6 +169,8 @@ def _emit_function(function: value_spec_t.Function, indent: str) -> str:
164
169
  f"{sub_indent}description={encode_common_string(function.description)},\n"
165
170
  )
166
171
  out.write(f"{sub_indent}brief={encode_common_string(function.brief)},\n")
172
+ if function.draft:
173
+ out.write(f"{sub_indent}draft={function.draft},\n")
167
174
  out.write(
168
175
  f"{sub_indent}return_value={_emit_function_return(function.return_value, sub_indent)},\n"
169
176
  )
@@ -189,7 +196,10 @@ def _emit_argument(argument: value_spec_t.FunctionArgument, indent: str) -> str:
189
196
  out.write(
190
197
  f"{sub_indent}description={encode_common_string(argument.description)},\n"
191
198
  )
192
- out.write(f"{sub_indent}pass_null={str(argument.pass_null)},\n")
199
+ # Quick enum emit since we have only one such type here
200
+ out.write(
201
+ f"{sub_indent}on_null=value_spec_t.OnNull.{str(argument.on_null).upper()},\n"
202
+ )
193
203
  out.write(
194
204
  f"{sub_indent}extant=value_spec_t.ArgumentExtant.{argument.extant.name},\n"
195
205
  )
@@ -8,7 +8,7 @@ from ..util import ParsedTypePath
8
8
  @dataclass(kw_only=True, frozen=True)
9
9
  class ParsedFunctionArgument:
10
10
  ref_name: str
11
- pass_null: bool
11
+ on_null: value_spec_t.OnNull
12
12
  extant: value_spec_t.ArgumentExtant
13
13
  type_path: ParsedTypePath
14
14
 
@@ -4,7 +4,7 @@ from uncountable.types.async_batch import AsyncBatchRequest
4
4
  from uncountable.types.async_batch_processor import AsyncBatchProcessorBase
5
5
 
6
6
 
7
- class AsyncBatchSubmissionError(BaseException):
7
+ class AsyncBatchSubmissionError(Exception):
8
8
  pass
9
9
 
10
10
 
@@ -1,9 +1,12 @@
1
1
  import base64
2
+ import datetime
3
+ import re
2
4
  import typing
3
5
  from dataclasses import dataclass
4
- from datetime import datetime, timedelta
6
+ from datetime import UTC, timedelta
5
7
  from enum import StrEnum
6
- from urllib.parse import urljoin
8
+ from io import BytesIO
9
+ from urllib.parse import unquote, urljoin
7
10
  from uuid import uuid4
8
11
 
9
12
  import requests
@@ -12,10 +15,10 @@ from opentelemetry.sdk.resources import Attributes
12
15
  from requests.exceptions import JSONDecodeError
13
16
 
14
17
  from pkgs.argument_parser import CachedParser
15
- from pkgs.serialization_util import serialize_for_api
16
- from pkgs.serialization_util.serialization_helpers import JsonValue
18
+ from pkgs.serialization_util import JsonValue, serialize_for_api
17
19
  from uncountable.core.environment import get_version
18
- from uncountable.integration.telemetry import JobLogger
20
+ from uncountable.integration.telemetry import Logger, push_scope_optional
21
+ from uncountable.types import download_file_t
19
22
  from uncountable.types.client_base import APIRequest, ClientMethods
20
23
  from uncountable.types.client_config import ClientConfigOptions
21
24
 
@@ -41,14 +44,14 @@ class HTTPRequestBase:
41
44
 
42
45
  @dataclass(kw_only=True)
43
46
  class HTTPGetRequest(HTTPRequestBase):
44
- method = EndpointMethod.GET
47
+ method: EndpointMethod = EndpointMethod.GET
45
48
  query_params: dict[str, str]
46
49
 
47
50
 
48
51
  @dataclass(kw_only=True)
49
52
  class HTTPPostRequest(HTTPRequestBase):
50
- method = EndpointMethod.POST
51
- body: typing.Union[str, dict[str, str]]
53
+ method: EndpointMethod = EndpointMethod.POST
54
+ body: str | dict[str, str]
52
55
 
53
56
 
54
57
  HTTPRequest = HTTPPostRequest | HTTPGetRequest
@@ -59,7 +62,7 @@ class ClientConfig(ClientConfigOptions):
59
62
  transform_request: typing.Callable[[requests.Request], requests.Request] | None = (
60
63
  None
61
64
  )
62
- job_logger: typing.Optional[JobLogger] = None
65
+ logger: Logger | None = None
63
66
 
64
67
 
65
68
  OAUTH_REFRESH_WINDOW_SECONDS = 60 * 5
@@ -71,16 +74,24 @@ class APIResponseError(Exception):
71
74
  extra_details: dict[str, JsonValue] | None
72
75
 
73
76
  def __init__(
74
- self, status_code: int, message: str, extra_details: dict[str, JsonValue] | None
77
+ self,
78
+ status_code: int,
79
+ message: str,
80
+ extra_details: dict[str, JsonValue] | None,
81
+ request_id: str,
75
82
  ) -> None:
76
83
  super().__init__(status_code, message, extra_details)
77
84
  self.status_code = status_code
78
85
  self.message = message
79
86
  self.extra_details = extra_details
87
+ self.request_id = request_id
80
88
 
81
89
  @classmethod
82
90
  def construct_error(
83
- cls, status_code: int, extra_details: dict[str, JsonValue] | None
91
+ cls,
92
+ status_code: int,
93
+ extra_details: dict[str, JsonValue] | None,
94
+ request_id: str,
84
95
  ) -> "APIResponseError":
85
96
  message: str
86
97
  match status_code:
@@ -103,9 +114,21 @@ class APIResponseError(Exception):
103
114
  case _:
104
115
  message = "unknown error"
105
116
  return APIResponseError(
106
- status_code=status_code, message=message, extra_details=extra_details
117
+ status_code=status_code,
118
+ message=message,
119
+ extra_details=extra_details,
120
+ request_id=request_id,
107
121
  )
108
122
 
123
+ def __str__(self) -> str:
124
+ details_obj = {
125
+ "request_id": self.request_id,
126
+ "status_code": self.status_code,
127
+ "extra_details": self.extra_details,
128
+ }
129
+ details = json.dumps(details_obj)
130
+ return f"API response error ({self.status_code}): '{self.message}'. Details: {details}"
131
+
109
132
 
110
133
  class SDKError(Exception):
111
134
  message: str
@@ -123,7 +146,7 @@ class SDKError(Exception):
123
146
  @dataclass(kw_only=True)
124
147
  class OAuthBearerTokenCache:
125
148
  token: str
126
- expires_at: datetime
149
+ expires_at: datetime.datetime
127
150
 
128
151
 
129
152
  @dataclass(kw_only=True)
@@ -137,6 +160,16 @@ class GetOauthBearerTokenData:
137
160
  oauth_bearer_token_data_parser = CachedParser(GetOauthBearerTokenData)
138
161
 
139
162
 
163
+ @dataclass
164
+ class DownloadedFile:
165
+ name: str
166
+ size: int
167
+ data: BytesIO
168
+
169
+
170
+ DownloadedFiles = list[DownloadedFile]
171
+
172
+
140
173
  class Client(ClientMethods):
141
174
  _parser_map: dict[type, CachedParser] = {}
142
175
  _auth_details: AuthDetailsAll
@@ -159,35 +192,49 @@ class Client(ClientMethods):
159
192
  self._session = requests.Session()
160
193
  self._session.verify = not self._cfg.allow_insecure_tls
161
194
  self._file_uploader = FileUploader(
162
- self._base_url, self._auth_details, self._cfg.allow_insecure_tls
195
+ self._base_url,
196
+ self._auth_details,
197
+ self._cfg.allow_insecure_tls,
198
+ logger=self._cfg.logger,
163
199
  )
164
200
 
165
- def _get_response_json(
166
- self, response: requests.Response, request_id: str
167
- ) -> dict[str, JsonValue]:
201
+ @classmethod
202
+ def _validate_response_status(
203
+ cls, response: requests.Response, request_id: str
204
+ ) -> None:
168
205
  if response.status_code < 200 or response.status_code > 299:
169
206
  extra_details: dict[str, JsonValue] | None = None
170
207
  try:
171
208
  data = response.json()
172
- if "error" in data:
173
- extra_details = data
209
+ extra_details = data
174
210
  except JSONDecodeError:
175
- pass
211
+ extra_details = {
212
+ "body": response.text,
213
+ }
176
214
  raise APIResponseError.construct_error(
177
- status_code=response.status_code, extra_details=extra_details
215
+ status_code=response.status_code,
216
+ extra_details=extra_details,
217
+ request_id=request_id,
178
218
  )
219
+
220
+ def _get_response_json(
221
+ self, response: requests.Response, request_id: str
222
+ ) -> dict[str, JsonValue]:
223
+ self._validate_response_status(response, request_id)
179
224
  try:
180
225
  return typing.cast(dict[str, JsonValue], response.json())
181
226
  except JSONDecodeError as e:
182
227
  raise SDKError("unable to process response", request_id=request_id) from e
183
228
 
184
- def _send_request(self, request: requests.Request) -> requests.Response:
229
+ def _send_request(
230
+ self, request: requests.Request, *, timeout: float | None = None
231
+ ) -> requests.Response:
185
232
  if self._cfg.extra_headers is not None:
186
233
  request.headers = {**request.headers, **self._cfg.extra_headers}
187
234
  if self._cfg.transform_request is not None:
188
235
  request = self._cfg.transform_request(request)
189
236
  prepared_request = request.prepare()
190
- response = self._session.send(prepared_request)
237
+ response = self._session.send(prepared_request, timeout=timeout)
191
238
  return response
192
239
 
193
240
  def do_request(self, *, api_request: APIRequest, return_type: type[DT]) -> DT:
@@ -205,15 +252,19 @@ class Client(ClientMethods):
205
252
  case _:
206
253
  typing.assert_never(http_request)
207
254
  request.headers = http_request.headers
208
- if self._cfg.job_logger is not None:
209
- attributes: Attributes = {
210
- "method": http_request.method,
211
- "endpoint": api_request.endpoint,
212
- }
213
- with self._cfg.job_logger.push_scope("api_call", attributes=attributes):
214
- response = self._send_request(request)
215
- else:
216
- response = self._send_request(request)
255
+ attributes: Attributes = {
256
+ "method": http_request.method,
257
+ "endpoint": api_request.endpoint,
258
+ }
259
+ with push_scope_optional(self._cfg.logger, "api_call", attributes=attributes):
260
+ if self._cfg.logger is not None:
261
+ self._cfg.logger.log_info(api_request.endpoint, attributes=attributes)
262
+ timeout = (
263
+ api_request.request_options.timeout_secs
264
+ if api_request.request_options is not None
265
+ else None
266
+ )
267
+ response = self._send_request(request, timeout=timeout)
217
268
  response_data = self._get_response_json(response, request_id=request_id)
218
269
  cached_parser = self._get_cached_parser(return_type)
219
270
  try:
@@ -231,7 +282,8 @@ class Client(ClientMethods):
231
282
  if (
232
283
  self._oauth_bearer_token_cache is None
233
284
  or (
234
- self._oauth_bearer_token_cache.expires_at - datetime.now()
285
+ self._oauth_bearer_token_cache.expires_at
286
+ - datetime.datetime.now(tz=UTC)
235
287
  ).total_seconds()
236
288
  < OAUTH_REFRESH_WINDOW_SECONDS
237
289
  ):
@@ -247,7 +299,8 @@ class Client(ClientMethods):
247
299
  token_data = oauth_bearer_token_data_parser.parse_storage(data)
248
300
  self._oauth_bearer_token_cache = OAuthBearerTokenCache(
249
301
  token=token_data.access_token,
250
- expires_at=datetime.now() + timedelta(seconds=token_data.expires_in),
302
+ expires_at=datetime.datetime.now(tz=UTC)
303
+ + timedelta(seconds=token_data.expires_in),
251
304
  )
252
305
 
253
306
  return self._oauth_bearer_token_cache.token
@@ -290,6 +343,52 @@ class Client(ClientMethods):
290
343
  case _:
291
344
  raise ValueError(f"unsupported request method: {method}")
292
345
 
346
+ def _get_downloaded_filename(self, *, cd: str | None) -> str:
347
+ if not cd:
348
+ return "Unknown"
349
+
350
+ fname = re.findall(r"filename\*=UTF-8''(.+)", cd)
351
+ if fname:
352
+ return unquote(fname[0])
353
+
354
+ fname = re.findall(r'filename="?(.+)"?', cd)
355
+ if fname:
356
+ return str(fname[0].strip('"'))
357
+
358
+ return "Unknown"
359
+
360
+ def download_files(
361
+ self, *, file_query: download_file_t.FileDownloadQuery
362
+ ) -> DownloadedFiles:
363
+ """Download a file from uncountable."""
364
+ request_id = str(uuid4())
365
+ api_request = APIRequest(
366
+ method=download_file_t.ENDPOINT_METHOD,
367
+ endpoint=download_file_t.ENDPOINT_PATH,
368
+ args=download_file_t.Arguments(
369
+ file_query=file_query,
370
+ ),
371
+ )
372
+ http_request = self._build_http_request(
373
+ api_request=api_request, request_id=request_id
374
+ )
375
+ request = requests.Request(http_request.method.value, http_request.url)
376
+ request.headers = http_request.headers
377
+ assert isinstance(http_request, HTTPGetRequest)
378
+ request.params = http_request.query_params
379
+ response = self._send_request(request)
380
+ self._validate_response_status(response, request_id)
381
+
382
+ content = response.content
383
+ content_disposition = response.headers.get("Content-Disposition", None)
384
+ return [
385
+ DownloadedFile(
386
+ name=self._get_downloaded_filename(cd=content_disposition),
387
+ size=len(content),
388
+ data=BytesIO(content),
389
+ )
390
+ ]
391
+
293
392
  def upload_files(
294
393
  self: typing.Self, *, file_uploads: list[FileUpload]
295
394
  ) -> list[UploadedFile]:
@@ -18,12 +18,12 @@ def get_server_env() -> str | None:
18
18
  return os.environ.get("UNC_SERVER_ENV")
19
19
 
20
20
 
21
- def get_webhook_server_port() -> int:
22
- return int(os.environ.get("UNC_WEBHOOK_SERVER_PORT", 5001))
21
+ def get_http_server_port() -> int:
22
+ return int(os.environ.get("UNC_WEBHOOK_SERVER_PORT", "5001"))
23
23
 
24
24
 
25
25
  def get_local_admin_server_port() -> int:
26
- return int(os.environ.get("UNC_ADMIN_SERVER_PORT", 50051))
26
+ return int(os.environ.get("UNC_ADMIN_SERVER_PORT", "50051"))
27
27
 
28
28
 
29
29
  def get_otel_enabled() -> bool:
@@ -4,11 +4,13 @@ from dataclasses import dataclass
4
4
  from enum import StrEnum
5
5
  from io import BytesIO
6
6
  from pathlib import Path
7
- from typing import Generator, Literal, Self
7
+ from typing import Generator, Literal, Self, assert_never
8
8
 
9
9
  import aiohttp
10
10
  import aiotus
11
11
 
12
+ from uncountable.integration.telemetry import Logger, push_scope_optional
13
+
12
14
  from .types import AuthDetailsAll, AuthDetailsApiKey
13
15
 
14
16
  _CHUNK_SIZE = 5 * 1024 * 1024 # s3 requires 5MiB minimum
@@ -75,10 +77,12 @@ class FileUploader:
75
77
  base_url: str,
76
78
  auth_details: AuthDetailsAll,
77
79
  allow_insecure_tls: bool = False,
80
+ logger: Logger | None = None,
78
81
  ) -> None:
79
82
  self._base_url = base_url
80
83
  self._auth_details = auth_details
81
84
  self._allow_insecure_tls = allow_insecure_tls
85
+ self._logger = logger
82
86
 
83
87
  async def _upload_file(self: Self, file_upload: FileUpload) -> UploadedFile:
84
88
  creation_url = f"{self._base_url}/api/external/file_upload/files"
@@ -93,20 +97,40 @@ class FileUploader:
93
97
  auth=auth, headers={"Origin": self._base_url}
94
98
  ) as session,
95
99
  ):
96
- with file_upload_data(file_upload) as file_bytes:
97
- location = await aiotus.upload(
98
- creation_url,
99
- file_bytes.bytes_data,
100
- {"filename": file_bytes.name.encode()},
101
- client_session=session,
102
- config=aiotus.RetryConfiguration(ssl=not self._allow_insecure_tls),
103
- chunksize=_CHUNK_SIZE,
104
- )
105
- if location is None:
106
- raise UploadFailed(f"Failed to upload: {file_bytes.name}")
107
- return UploadedFile(
108
- name=file_bytes.name, file_id=int(location.path.split("/")[-1])
109
- )
100
+ attributes = {}
101
+ match file_upload:
102
+ case MediaFileUpload():
103
+ attributes["file_path"] = file_upload.path
104
+ case DataFileUpload():
105
+ attributes["file_name"] = file_upload.name
106
+ case _:
107
+ assert_never(file_upload)
108
+ with push_scope_optional(
109
+ self._logger, "upload_file", attributes=attributes
110
+ ):
111
+ if self._logger is not None:
112
+ self._logger.log_info("Uploading file", attributes=attributes)
113
+ with file_upload_data(file_upload) as file_bytes:
114
+ if file_bytes.bytes_data.read(1) == b"":
115
+ raise UploadFailed(
116
+ f"Failed to upload empty file: {file_bytes.name}"
117
+ )
118
+ file_bytes.bytes_data.seek(0)
119
+ location = await aiotus.upload(
120
+ creation_url,
121
+ file_bytes.bytes_data,
122
+ {"filename": file_bytes.name.encode()},
123
+ client_session=session,
124
+ config=aiotus.RetryConfiguration(
125
+ ssl=not self._allow_insecure_tls
126
+ ),
127
+ chunksize=_CHUNK_SIZE,
128
+ )
129
+ if location is None:
130
+ raise UploadFailed(f"Failed to upload: {file_bytes.name}")
131
+ return UploadedFile(
132
+ name=file_bytes.name, file_id=int(location.path.split("/")[-1])
133
+ )
110
134
 
111
135
  def upload_files(
112
136
  self: Self, *, file_uploads: list[FileUpload]
@@ -1,49 +1,142 @@
1
1
  import argparse
2
2
 
3
+ from dateutil import tz
3
4
  from opentelemetry.trace import get_current_span
5
+ from tabulate import tabulate
4
6
 
5
7
  from uncountable.core.environment import get_local_admin_server_port
6
8
  from uncountable.integration.queue_runner.command_server.command_client import (
7
9
  send_job_queue_message,
10
+ send_list_queued_jobs_message,
11
+ send_retry_job_message,
8
12
  )
9
13
  from uncountable.integration.telemetry import Logger
10
14
  from uncountable.types import queued_job_t
11
15
 
12
16
 
13
- def main() -> None:
14
- logger = Logger(get_current_span())
17
+ def register_enqueue_job_parser(
18
+ sub_parser_manager: argparse._SubParsersAction,
19
+ parents: list[argparse.ArgumentParser],
20
+ ) -> None:
21
+ run_parser = sub_parser_manager.add_parser(
22
+ "run",
23
+ parents=parents,
24
+ help="Process a job with a given host and job ID",
25
+ description="Process a job with a given host and job ID",
26
+ )
27
+ run_parser.add_argument("job_id", type=str, help="The ID of the job to process")
28
+
29
+ def _handle_enqueue_job(args: argparse.Namespace) -> None:
30
+ send_job_queue_message(
31
+ job_ref_name=args.job_id,
32
+ payload=queued_job_t.QueuedJobPayload(
33
+ invocation_context=queued_job_t.InvocationContextManual()
34
+ ),
35
+ host=args.host,
36
+ port=get_local_admin_server_port(),
37
+ )
15
38
 
16
- parser = argparse.ArgumentParser(
17
- description="Process a job with a given command and job ID."
39
+ run_parser.set_defaults(func=_handle_enqueue_job)
40
+
41
+
42
+ def register_list_queued_jobs(
43
+ sub_parser_manager: argparse._SubParsersAction,
44
+ parents: list[argparse.ArgumentParser],
45
+ ) -> None:
46
+ list_queued_jobs_parser = sub_parser_manager.add_parser(
47
+ "list-queued-jobs",
48
+ parents=parents,
49
+ help="List all jobs queued on the integration server",
50
+ description="List all jobs queued on the integration server",
18
51
  )
19
52
 
20
- parser.add_argument(
21
- "command",
22
- type=str,
23
- choices=["run"],
24
- help="The command to execute (e.g., 'run')",
53
+ list_queued_jobs_parser.add_argument(
54
+ "--offset",
55
+ type=int,
56
+ default=0,
57
+ help="Number of jobs to skip. Should be non-negative.",
58
+ )
59
+ list_queued_jobs_parser.add_argument(
60
+ "--limit",
61
+ type=int,
62
+ default=100,
63
+ help="A number between 1 and 100 specifying the number of jobs to return in the result set.",
64
+ )
65
+
66
+ def _handle_list_queued_jobs(args: argparse.Namespace) -> None:
67
+ queued_jobs = send_list_queued_jobs_message(
68
+ offset=args.offset,
69
+ limit=args.limit,
70
+ host=args.host,
71
+ port=get_local_admin_server_port(),
72
+ )
73
+
74
+ headers = ["UUID", "Job Ref Name", "Attempts", "Status", "Submitted At"]
75
+ rows = [
76
+ [
77
+ job.uuid,
78
+ job.job_ref_name,
79
+ job.num_attempts,
80
+ job.status,
81
+ job.submitted_at.ToDatetime(tz.UTC).astimezone(tz.tzlocal()),
82
+ ]
83
+ for job in queued_jobs
84
+ ]
85
+ print(tabulate(rows, headers=headers, tablefmt="grid"))
86
+
87
+ list_queued_jobs_parser.set_defaults(func=_handle_list_queued_jobs)
88
+
89
+
90
+ def register_retry_job_parser(
91
+ sub_parser_manager: argparse._SubParsersAction,
92
+ parents: list[argparse.ArgumentParser],
93
+ ) -> None:
94
+ retry_failed_jobs_parser = sub_parser_manager.add_parser(
95
+ "retry-job",
96
+ parents=parents,
97
+ help="Retry failed job on the integration server",
98
+ description="Retry failed job on the integration server",
99
+ )
100
+
101
+ retry_failed_jobs_parser.add_argument(
102
+ "job_uuid", type=str, help="The uuid of the job to retry"
25
103
  )
26
104
 
27
- parser.add_argument("job_id", type=str, help="The ID of the job to process")
105
+ def _handle_retry_job(args: argparse.Namespace) -> None:
106
+ send_retry_job_message(
107
+ job_uuid=args.job_uuid,
108
+ host=args.host,
109
+ port=get_local_admin_server_port(),
110
+ )
111
+
112
+ retry_failed_jobs_parser.set_defaults(func=_handle_retry_job)
28
113
 
29
- parser.add_argument(
114
+
115
+ def main() -> None:
116
+ logger = Logger(get_current_span())
117
+
118
+ main_parser = argparse.ArgumentParser(
119
+ description="Execute a given integrations server command."
120
+ )
121
+
122
+ base_parser = argparse.ArgumentParser(add_help=False)
123
+ base_parser.add_argument(
30
124
  "--host", type=str, default="localhost", nargs="?", help="The host to run on"
31
125
  )
32
126
 
33
- args = parser.parse_args()
127
+ subparser_action = main_parser.add_subparsers(
128
+ dest="command",
129
+ required=True,
130
+ help="The command to execute (e.g., 'run')",
131
+ )
132
+
133
+ register_enqueue_job_parser(subparser_action, parents=[base_parser])
134
+ register_retry_job_parser(subparser_action, parents=[base_parser])
135
+ register_list_queued_jobs(subparser_action, parents=[base_parser])
34
136
 
137
+ args = main_parser.parse_args()
35
138
  with logger.push_scope(args.command):
36
- if args.command == "run":
37
- send_job_queue_message(
38
- job_ref_name=args.job_id,
39
- payload=queued_job_t.QueuedJobPayload(
40
- invocation_context=queued_job_t.InvocationContextManual()
41
- ),
42
- host=args.host,
43
- port=get_local_admin_server_port(),
44
- )
45
- else:
46
- parser.print_usage()
139
+ args.func(args)
47
140
 
48
141
 
49
142
  main()
@@ -37,15 +37,15 @@ def _construct_client_config(
37
37
  return ClientConfig(
38
38
  allow_insecure_tls=profile_meta.client_options.allow_insecure_tls,
39
39
  extra_headers=profile_meta.client_options.extra_headers,
40
- job_logger=job_logger,
40
+ logger=job_logger,
41
41
  )
42
42
 
43
43
 
44
44
  def construct_uncountable_client(
45
- profile_meta: ProfileMetadata, job_logger: JobLogger
45
+ profile_meta: ProfileMetadata, logger: JobLogger
46
46
  ) -> Client:
47
47
  return Client(
48
48
  base_url=profile_meta.base_url,
49
49
  auth_details=_construct_auth_details(profile_meta),
50
- config=_construct_client_config(profile_meta, job_logger),
50
+ config=_construct_client_config(profile_meta, logger),
51
51
  )