UncountablePythonSDK 0.0.7__py3-none-any.whl → 0.0.92__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (311) hide show
  1. UncountablePythonSDK-0.0.92.dist-info/METADATA +61 -0
  2. UncountablePythonSDK-0.0.92.dist-info/RECORD +301 -0
  3. {UncountablePythonSDK-0.0.7.dist-info → UncountablePythonSDK-0.0.92.dist-info}/WHEEL +1 -1
  4. {UncountablePythonSDK-0.0.7.dist-info → UncountablePythonSDK-0.0.92.dist-info}/top_level.txt +1 -1
  5. docs/.gitignore +1 -0
  6. docs/conf.py +57 -0
  7. docs/index.md +13 -0
  8. docs/justfile +12 -0
  9. docs/quickstart.md +19 -0
  10. docs/requirements.txt +7 -0
  11. docs/static/favicons/android-chrome-192x192.png +0 -0
  12. docs/static/favicons/android-chrome-512x512.png +0 -0
  13. docs/static/favicons/apple-touch-icon.png +0 -0
  14. docs/static/favicons/browserconfig.xml +9 -0
  15. docs/static/favicons/favicon-16x16.png +0 -0
  16. docs/static/favicons/favicon-32x32.png +0 -0
  17. docs/static/favicons/manifest.json +18 -0
  18. docs/static/favicons/mstile-150x150.png +0 -0
  19. docs/static/favicons/safari-pinned-tab.svg +32 -0
  20. docs/static/logo_blue.png +0 -0
  21. examples/async_batch.py +35 -0
  22. examples/create_entity.py +22 -17
  23. examples/download_files.py +26 -0
  24. examples/edit_recipe_inputs.py +50 -0
  25. examples/integration-server/jobs/materials_auto/example_cron.py +18 -0
  26. examples/integration-server/jobs/materials_auto/example_wh.py +15 -0
  27. examples/integration-server/jobs/materials_auto/profile.yaml +43 -0
  28. examples/integration-server/pyproject.toml +224 -0
  29. examples/invoke_uploader.py +26 -0
  30. examples/set_recipe_metadata_file.py +40 -0
  31. examples/set_recipe_output_file_sdk.py +26 -0
  32. examples/upload_files.py +18 -0
  33. pkgs/argument_parser/__init__.py +5 -0
  34. pkgs/argument_parser/_is_enum.py +1 -6
  35. pkgs/argument_parser/argument_parser.py +232 -76
  36. pkgs/argument_parser/case_convert.py +4 -3
  37. pkgs/filesystem_utils/__init__.py +20 -0
  38. pkgs/filesystem_utils/_blob_session.py +137 -0
  39. pkgs/filesystem_utils/_gdrive_session.py +309 -0
  40. pkgs/filesystem_utils/_local_session.py +69 -0
  41. pkgs/filesystem_utils/_s3_session.py +117 -0
  42. pkgs/filesystem_utils/_sftp_session.py +147 -0
  43. pkgs/filesystem_utils/file_type_utils.py +91 -0
  44. pkgs/filesystem_utils/filesystem_session.py +39 -0
  45. pkgs/py.typed +0 -0
  46. pkgs/serialization/__init__.py +8 -1
  47. pkgs/serialization/annotation.py +64 -0
  48. pkgs/serialization/opaque_key.py +1 -1
  49. pkgs/serialization/serial_alias.py +47 -0
  50. pkgs/serialization/serial_class.py +65 -50
  51. pkgs/serialization/serial_generic.py +16 -0
  52. pkgs/serialization/serial_union.py +84 -0
  53. pkgs/serialization/yaml.py +57 -0
  54. pkgs/serialization_util/__init__.py +7 -7
  55. pkgs/serialization_util/_get_type_for_serialization.py +1 -3
  56. pkgs/serialization_util/convert_to_snakecase.py +27 -0
  57. pkgs/serialization_util/dataclasses.py +14 -0
  58. pkgs/serialization_util/serialization_helpers.py +118 -73
  59. pkgs/strenum_compat/strenum_compat.py +1 -9
  60. pkgs/type_spec/actions_registry/__init__.py +0 -0
  61. pkgs/type_spec/actions_registry/__main__.py +126 -0
  62. pkgs/type_spec/actions_registry/emit_typescript.py +182 -0
  63. pkgs/type_spec/builder.py +475 -89
  64. pkgs/type_spec/config.py +24 -19
  65. pkgs/type_spec/emit_io_ts.py +5 -2
  66. pkgs/type_spec/emit_open_api.py +266 -32
  67. pkgs/type_spec/emit_open_api_util.py +32 -13
  68. pkgs/type_spec/emit_python.py +601 -150
  69. pkgs/type_spec/emit_typescript.py +74 -273
  70. pkgs/type_spec/emit_typescript_util.py +239 -5
  71. pkgs/type_spec/load_types.py +55 -10
  72. pkgs/type_spec/open_api_util.py +30 -41
  73. pkgs/type_spec/parts/base.py.prepart +4 -3
  74. pkgs/type_spec/type_info/emit_type_info.py +178 -16
  75. pkgs/type_spec/util.py +11 -11
  76. pkgs/type_spec/value_spec/__main__.py +3 -3
  77. pkgs/type_spec/value_spec/convert_type.py +8 -1
  78. pkgs/type_spec/value_spec/emit_python.py +13 -4
  79. uncountable/__init__.py +1 -2
  80. uncountable/core/__init__.py +12 -2
  81. uncountable/core/async_batch.py +37 -0
  82. uncountable/core/client.py +293 -43
  83. uncountable/core/environment.py +41 -0
  84. uncountable/core/file_upload.py +135 -0
  85. uncountable/core/types.py +17 -0
  86. uncountable/integration/__init__.py +0 -0
  87. uncountable/integration/cli.py +49 -0
  88. uncountable/integration/construct_client.py +51 -0
  89. uncountable/integration/cron.py +29 -0
  90. uncountable/integration/db/__init__.py +0 -0
  91. uncountable/integration/db/connect.py +18 -0
  92. uncountable/integration/db/session.py +25 -0
  93. uncountable/integration/entrypoint.py +13 -0
  94. uncountable/integration/executors/__init__.py +0 -0
  95. uncountable/integration/executors/executors.py +148 -0
  96. uncountable/integration/executors/generic_upload_executor.py +284 -0
  97. uncountable/integration/executors/script_executor.py +25 -0
  98. uncountable/integration/job.py +87 -0
  99. uncountable/integration/queue_runner/__init__.py +0 -0
  100. uncountable/integration/queue_runner/command_server/__init__.py +24 -0
  101. uncountable/integration/queue_runner/command_server/command_client.py +68 -0
  102. uncountable/integration/queue_runner/command_server/command_server.py +64 -0
  103. uncountable/integration/queue_runner/command_server/protocol/__init__.py +0 -0
  104. uncountable/integration/queue_runner/command_server/protocol/command_server.proto +22 -0
  105. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py +40 -0
  106. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi +38 -0
  107. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2_grpc.py +129 -0
  108. uncountable/integration/queue_runner/command_server/types.py +52 -0
  109. uncountable/integration/queue_runner/datastore/__init__.py +3 -0
  110. uncountable/integration/queue_runner/datastore/datastore_sqlite.py +93 -0
  111. uncountable/integration/queue_runner/datastore/interface.py +19 -0
  112. uncountable/integration/queue_runner/datastore/model.py +17 -0
  113. uncountable/integration/queue_runner/job_scheduler.py +163 -0
  114. uncountable/integration/queue_runner/queue_runner.py +26 -0
  115. uncountable/integration/queue_runner/types.py +7 -0
  116. uncountable/integration/queue_runner/worker.py +119 -0
  117. uncountable/integration/scan_profiles.py +67 -0
  118. uncountable/integration/scheduler.py +150 -0
  119. uncountable/integration/secret_retrieval/__init__.py +3 -0
  120. uncountable/integration/secret_retrieval/retrieve_secret.py +93 -0
  121. uncountable/integration/server.py +117 -0
  122. uncountable/integration/telemetry.py +209 -0
  123. uncountable/integration/webhook_server/entrypoint.py +170 -0
  124. uncountable/types/__init__.py +151 -5
  125. uncountable/types/api/batch/execute_batch.py +15 -7
  126. uncountable/types/api/batch/execute_batch_load_async.py +42 -0
  127. uncountable/types/api/chemical/__init__.py +1 -0
  128. uncountable/types/api/chemical/convert_chemical_formats.py +63 -0
  129. uncountable/types/api/entity/create_entities.py +23 -10
  130. uncountable/types/api/entity/create_entity.py +21 -12
  131. uncountable/types/api/entity/get_entities_data.py +19 -29
  132. uncountable/types/api/entity/grant_entity_permissions.py +48 -0
  133. uncountable/types/api/entity/list_entities.py +28 -20
  134. uncountable/types/api/entity/lock_entity.py +45 -0
  135. uncountable/types/api/entity/resolve_entity_ids.py +19 -7
  136. uncountable/types/api/entity/set_entity_field_values.py +44 -0
  137. uncountable/types/api/entity/set_values.py +13 -28
  138. uncountable/types/api/entity/transition_entity_phase.py +80 -0
  139. uncountable/types/api/entity/unlock_entity.py +44 -0
  140. uncountable/types/api/equipment/__init__.py +1 -0
  141. uncountable/types/api/equipment/associate_equipment_input.py +44 -0
  142. uncountable/types/api/field_options/__init__.py +1 -0
  143. uncountable/types/api/field_options/upsert_field_options.py +55 -0
  144. uncountable/types/api/files/__init__.py +1 -0
  145. uncountable/types/api/files/download_file.py +77 -0
  146. uncountable/types/api/id_source/__init__.py +1 -0
  147. uncountable/types/api/id_source/list_id_source.py +56 -0
  148. uncountable/types/api/id_source/match_id_source.py +54 -0
  149. uncountable/types/api/input_groups/get_input_group_names.py +18 -7
  150. uncountable/types/api/inputs/create_inputs.py +25 -24
  151. uncountable/types/api/inputs/get_input_data.py +37 -31
  152. uncountable/types/api/inputs/get_input_names.py +20 -9
  153. uncountable/types/api/inputs/get_inputs_data.py +33 -27
  154. uncountable/types/api/inputs/set_input_attribute_values.py +18 -13
  155. uncountable/types/api/inputs/set_input_category.py +44 -0
  156. uncountable/types/api/inputs/set_input_subcategories.py +45 -0
  157. uncountable/types/api/inputs/set_intermediate_type.py +50 -0
  158. uncountable/types/api/material_families/__init__.py +1 -0
  159. uncountable/types/api/material_families/update_entity_material_families.py +48 -0
  160. uncountable/types/api/outputs/get_output_data.py +38 -29
  161. uncountable/types/api/outputs/get_output_names.py +20 -9
  162. uncountable/types/api/outputs/resolve_output_conditions.py +23 -10
  163. uncountable/types/api/permissions/__init__.py +1 -0
  164. uncountable/types/api/permissions/set_core_permissions.py +105 -0
  165. uncountable/types/api/project/get_projects.py +23 -19
  166. uncountable/types/api/project/get_projects_data.py +26 -43
  167. uncountable/types/api/recipe_links/__init__.py +1 -0
  168. uncountable/types/api/recipe_links/create_recipe_link.py +46 -0
  169. uncountable/types/api/recipe_links/remove_recipe_link.py +45 -0
  170. uncountable/types/api/recipe_metadata/get_recipe_metadata_data.py +21 -10
  171. uncountable/types/api/recipes/add_recipe_to_project.py +42 -0
  172. uncountable/types/api/recipes/archive_recipes.py +42 -0
  173. uncountable/types/api/recipes/associate_recipe_as_input.py +44 -0
  174. uncountable/types/api/recipes/associate_recipe_as_lot.py +43 -0
  175. uncountable/types/api/recipes/clear_recipe_outputs.py +42 -0
  176. uncountable/types/api/recipes/create_recipe.py +51 -0
  177. uncountable/types/api/recipes/create_recipes.py +25 -24
  178. uncountable/types/api/recipes/disassociate_recipe_as_input.py +42 -0
  179. uncountable/types/api/recipes/edit_recipe_inputs.py +283 -0
  180. uncountable/types/api/recipes/get_column_calculation_values.py +58 -0
  181. uncountable/types/api/recipes/get_curve.py +13 -27
  182. uncountable/types/api/recipes/get_recipe_calculations.py +21 -21
  183. uncountable/types/api/recipes/get_recipe_links.py +14 -6
  184. uncountable/types/api/recipes/get_recipe_names.py +18 -7
  185. uncountable/types/api/recipes/get_recipe_output_metadata.py +18 -19
  186. uncountable/types/api/recipes/get_recipes_data.py +83 -144
  187. uncountable/types/api/recipes/lock_recipes.py +63 -0
  188. uncountable/types/api/recipes/remove_recipe_from_project.py +42 -0
  189. uncountable/types/api/recipes/set_recipe_inputs.py +21 -11
  190. uncountable/types/api/recipes/set_recipe_metadata.py +43 -0
  191. uncountable/types/api/recipes/set_recipe_output_annotations.py +115 -0
  192. uncountable/types/api/recipes/set_recipe_output_file.py +56 -0
  193. uncountable/types/api/recipes/set_recipe_outputs.py +28 -15
  194. uncountable/types/api/recipes/set_recipe_tags.py +109 -0
  195. uncountable/types/api/recipes/unarchive_recipes.py +41 -0
  196. uncountable/types/api/recipes/unlock_recipes.py +50 -0
  197. uncountable/types/api/triggers/__init__.py +1 -0
  198. uncountable/types/api/triggers/run_trigger.py +43 -0
  199. uncountable/types/api/uploader/__init__.py +1 -0
  200. uncountable/types/api/uploader/invoke_uploader.py +47 -0
  201. uncountable/types/async_batch.py +13 -0
  202. uncountable/types/async_batch_processor.py +384 -0
  203. uncountable/types/async_batch_t.py +97 -0
  204. uncountable/types/async_jobs.py +9 -0
  205. uncountable/types/async_jobs_t.py +53 -0
  206. uncountable/types/auth_retrieval.py +12 -0
  207. uncountable/types/auth_retrieval_t.py +75 -0
  208. uncountable/types/base.py +5 -78
  209. uncountable/types/base_t.py +85 -0
  210. uncountable/types/calculations.py +8 -0
  211. uncountable/types/calculations_t.py +27 -0
  212. uncountable/types/chemical_structure.py +8 -0
  213. uncountable/types/chemical_structure_t.py +28 -0
  214. uncountable/types/client_base.py +1115 -76
  215. uncountable/types/client_config.py +8 -0
  216. uncountable/types/client_config_t.py +26 -0
  217. uncountable/types/curves.py +10 -0
  218. uncountable/types/curves_t.py +51 -0
  219. uncountable/types/entity.py +8 -266
  220. uncountable/types/entity_t.py +393 -0
  221. uncountable/types/experiment_groups.py +8 -0
  222. uncountable/types/experiment_groups_t.py +27 -0
  223. uncountable/types/field_values.py +17 -23
  224. uncountable/types/field_values_t.py +204 -0
  225. uncountable/types/fields.py +8 -0
  226. uncountable/types/fields_t.py +28 -0
  227. uncountable/types/generic_upload.py +15 -0
  228. uncountable/types/generic_upload_t.py +119 -0
  229. uncountable/types/id_source.py +12 -0
  230. uncountable/types/id_source_t.py +68 -0
  231. uncountable/types/identifier.py +11 -0
  232. uncountable/types/identifier_t.py +63 -0
  233. uncountable/types/input_attributes.py +8 -0
  234. uncountable/types/input_attributes_t.py +30 -0
  235. uncountable/types/inputs.py +11 -0
  236. uncountable/types/inputs_t.py +83 -0
  237. uncountable/types/integration_server.py +9 -0
  238. uncountable/types/integration_server_t.py +42 -0
  239. uncountable/types/job_definition.py +27 -0
  240. uncountable/types/job_definition_t.py +260 -0
  241. uncountable/types/outputs.py +8 -0
  242. uncountable/types/outputs_t.py +30 -0
  243. uncountable/types/overrides.py +10 -0
  244. uncountable/types/overrides_t.py +49 -0
  245. uncountable/types/permissions.py +8 -0
  246. uncountable/types/permissions_t.py +46 -0
  247. uncountable/types/phases.py +8 -0
  248. uncountable/types/phases_t.py +27 -0
  249. uncountable/types/post_base.py +8 -0
  250. uncountable/types/post_base_t.py +30 -0
  251. uncountable/types/queued_job.py +16 -0
  252. uncountable/types/queued_job_t.py +123 -0
  253. uncountable/types/recipe_identifiers.py +12 -0
  254. uncountable/types/recipe_identifiers_t.py +76 -0
  255. uncountable/types/recipe_inputs.py +9 -0
  256. uncountable/types/recipe_inputs_t.py +30 -0
  257. uncountable/types/recipe_links.py +4 -44
  258. uncountable/types/recipe_links_t.py +54 -0
  259. uncountable/types/recipe_metadata.py +10 -0
  260. uncountable/types/recipe_metadata_t.py +58 -0
  261. uncountable/types/recipe_output_metadata.py +8 -0
  262. uncountable/types/recipe_output_metadata_t.py +28 -0
  263. uncountable/types/recipe_tags.py +8 -0
  264. uncountable/types/recipe_tags_t.py +27 -0
  265. uncountable/types/recipe_workflow_steps.py +14 -0
  266. uncountable/types/recipe_workflow_steps_t.py +95 -0
  267. uncountable/types/recipes.py +8 -0
  268. uncountable/types/recipes_t.py +25 -0
  269. uncountable/types/response.py +8 -0
  270. uncountable/types/response_t.py +26 -0
  271. uncountable/types/secret_retrieval.py +12 -0
  272. uncountable/types/secret_retrieval_t.py +75 -0
  273. uncountable/types/units.py +8 -0
  274. uncountable/types/units_t.py +27 -0
  275. uncountable/types/users.py +8 -0
  276. uncountable/types/users_t.py +28 -0
  277. uncountable/types/webhook_job.py +9 -0
  278. uncountable/types/webhook_job_t.py +37 -0
  279. uncountable/types/workflows.py +9 -0
  280. uncountable/types/workflows_t.py +39 -0
  281. UncountablePythonSDK-0.0.7.dist-info/METADATA +0 -27
  282. UncountablePythonSDK-0.0.7.dist-info/RECORD +0 -119
  283. examples/recipe-import/importer.py +0 -39
  284. type_spec/external/api/batch/execute_batch.yaml +0 -56
  285. type_spec/external/api/entity/create_entities.yaml +0 -33
  286. type_spec/external/api/entity/create_entity.yaml +0 -39
  287. type_spec/external/api/entity/get_entities_data.yaml +0 -55
  288. type_spec/external/api/entity/list_entities.yaml +0 -62
  289. type_spec/external/api/entity/resolve_entity_ids.yaml +0 -29
  290. type_spec/external/api/entity/set_values.yaml +0 -45
  291. type_spec/external/api/input_groups/get_input_group_names.yaml +0 -29
  292. type_spec/external/api/inputs/create_inputs.yaml +0 -61
  293. type_spec/external/api/inputs/get_input_data.yaml +0 -108
  294. type_spec/external/api/inputs/get_input_names.yaml +0 -38
  295. type_spec/external/api/inputs/get_inputs_data.yaml +0 -95
  296. type_spec/external/api/inputs/set_input_attribute_values.yaml +0 -37
  297. type_spec/external/api/outputs/get_output_data.yaml +0 -103
  298. type_spec/external/api/outputs/get_output_names.yaml +0 -35
  299. type_spec/external/api/outputs/resolve_output_conditions.yaml +0 -50
  300. type_spec/external/api/project/get_projects.yaml +0 -52
  301. type_spec/external/api/project/get_projects_data.yaml +0 -86
  302. type_spec/external/api/recipe_metadata/get_recipe_metadata_data.yaml +0 -41
  303. type_spec/external/api/recipes/create_recipes.yaml +0 -60
  304. type_spec/external/api/recipes/get_curve.yaml +0 -50
  305. type_spec/external/api/recipes/get_recipe_calculations.yaml +0 -49
  306. type_spec/external/api/recipes/get_recipe_links.yaml +0 -26
  307. type_spec/external/api/recipes/get_recipe_names.yaml +0 -29
  308. type_spec/external/api/recipes/get_recipe_output_metadata.yaml +0 -49
  309. type_spec/external/api/recipes/get_recipes_data.yaml +0 -372
  310. type_spec/external/api/recipes/set_recipe_inputs.yaml +0 -36
  311. type_spec/external/api/recipes/set_recipe_outputs.yaml +0 -56
@@ -1,17 +1,32 @@
1
- from dataclasses import dataclass
2
- from enum import StrEnum
3
- import json
4
- from urllib.parse import urljoin
5
1
  import base64
2
+ import re
6
3
  import typing
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta
6
+ from enum import StrEnum
7
+ from io import BytesIO
8
+ from urllib.parse import unquote, urljoin
9
+ from uuid import uuid4
10
+
7
11
  import requests
8
- from uncountable.types.base import JsonValue
9
- from uncountable.types.client_base import APIRequest, ClientMethods
12
+ import simplejson as json
13
+ from opentelemetry.sdk.resources import Attributes
14
+ from requests.exceptions import JSONDecodeError
15
+
10
16
  from pkgs.argument_parser import CachedParser
11
- from pkgs.serialization_util import serialize_for_api
17
+ from pkgs.serialization_util import JsonValue, serialize_for_api
18
+ from uncountable.core.environment import get_version
19
+ from uncountable.integration.telemetry import Logger, push_scope_optional
20
+ from uncountable.types import download_file_t
21
+ from uncountable.types.client_base import APIRequest, ClientMethods
22
+ from uncountable.types.client_config import ClientConfigOptions
12
23
 
24
+ from .file_upload import FileUpload, FileUploader, UploadedFile
25
+ from .types import AuthDetailsAll, AuthDetailsApiKey, AuthDetailsOAuth
13
26
 
14
27
  DT = typing.TypeVar("DT")
28
+ UNC_REQUEST_ID_HEADER = "X-UNC-REQUEST-ID"
29
+ UNC_SDK_VERSION_HEADER = "X-UNC-SDK-VERSION"
15
30
 
16
31
 
17
32
  class EndpointMethod(StrEnum):
@@ -24,105 +39,340 @@ class HTTPRequestBase:
24
39
  method: EndpointMethod
25
40
  url: str
26
41
  headers: dict[str, str]
27
- body: typing.Optional[str] = None
28
- query_params: typing.Optional[dict[str, str]]
29
42
 
30
43
 
31
44
  @dataclass(kw_only=True)
32
45
  class HTTPGetRequest(HTTPRequestBase):
33
- method: typing.Literal[EndpointMethod.GET]
46
+ method = EndpointMethod.GET
34
47
  query_params: dict[str, str]
35
48
 
36
49
 
37
50
  @dataclass(kw_only=True)
38
51
  class HTTPPostRequest(HTTPRequestBase):
39
- method: typing.Literal[EndpointMethod.POST]
52
+ method = EndpointMethod.POST
53
+ body: typing.Union[str, dict[str, str]]
40
54
 
41
55
 
42
56
  HTTPRequest = HTTPPostRequest | HTTPGetRequest
43
57
 
44
58
 
45
59
  @dataclass(kw_only=True)
46
- class AuthDetailsApiKey:
47
- api_id: str
48
- api_secret_key: str
60
+ class ClientConfig(ClientConfigOptions):
61
+ transform_request: typing.Callable[[requests.Request], requests.Request] | None = (
62
+ None
63
+ )
64
+ logger: typing.Optional[Logger] = None
65
+
49
66
 
67
+ OAUTH_REFRESH_WINDOW_SECONDS = 60 * 5
50
68
 
51
- AuthDetails = AuthDetailsApiKey
69
+
70
+ class APIResponseError(Exception):
71
+ status_code: int
72
+ message: str
73
+ extra_details: dict[str, JsonValue] | None
74
+
75
+ def __init__(
76
+ self,
77
+ status_code: int,
78
+ message: str,
79
+ extra_details: dict[str, JsonValue] | None,
80
+ request_id: str,
81
+ ) -> None:
82
+ super().__init__(status_code, message, extra_details)
83
+ self.status_code = status_code
84
+ self.message = message
85
+ self.extra_details = extra_details
86
+ self.request_id = request_id
87
+
88
+ @classmethod
89
+ def construct_error(
90
+ cls,
91
+ status_code: int,
92
+ extra_details: dict[str, JsonValue] | None,
93
+ request_id: str,
94
+ ) -> "APIResponseError":
95
+ message: str
96
+ match status_code:
97
+ case 403:
98
+ message = "unexpected: unauthorized"
99
+ case 410:
100
+ message = "unexpected: not found"
101
+ case 400:
102
+ message = "unexpected: bad arguments"
103
+ case 501:
104
+ message = "unexpected: unimplemented"
105
+ case 504:
106
+ message = "unexpected: timeout"
107
+ case 404:
108
+ message = "not found"
109
+ case 409:
110
+ message = "bad arguments"
111
+ case 422:
112
+ message = "unprocessable"
113
+ case _:
114
+ message = "unknown error"
115
+ return APIResponseError(
116
+ status_code=status_code,
117
+ message=message,
118
+ extra_details=extra_details,
119
+ request_id=request_id,
120
+ )
121
+
122
+ def __str__(self) -> str:
123
+ details_obj = {
124
+ "request_id": self.request_id,
125
+ "status_code": self.status_code,
126
+ "extra_details": self.extra_details,
127
+ }
128
+ details = json.dumps(details_obj)
129
+ return f"API response error ({self.status_code}): '{self.message}'. Details: {details}"
130
+
131
+
132
+ class SDKError(Exception):
133
+ message: str
134
+ request_id: str
135
+
136
+ def __init__(self, message: str, *, request_id: str) -> None:
137
+ super().__init__(message)
138
+ self.message = message
139
+ self.request_id = request_id
140
+
141
+ def __str__(self) -> str:
142
+ return f"internal SDK error (request id {self.request_id}), please contact Uncountable support: {self.message}"
143
+
144
+
145
+ @dataclass(kw_only=True)
146
+ class OAuthBearerTokenCache:
147
+ token: str
148
+ expires_at: datetime
149
+
150
+
151
+ @dataclass(kw_only=True)
152
+ class GetOauthBearerTokenData:
153
+ access_token: str
154
+ expires_in: int
155
+ token_type: str
156
+ scope: str
157
+
158
+
159
+ oauth_bearer_token_data_parser = CachedParser(GetOauthBearerTokenData)
160
+
161
+
162
+ @dataclass
163
+ class DownloadedFile:
164
+ name: str
165
+ size: int
166
+ data: BytesIO
167
+
168
+
169
+ DownloadedFiles = list[DownloadedFile]
52
170
 
53
171
 
54
172
  class Client(ClientMethods):
55
173
  _parser_map: dict[type, CachedParser] = {}
56
- _auth_details: AuthDetails
174
+ _auth_details: AuthDetailsAll
57
175
  _base_url: str
176
+ _file_uploader: FileUploader
177
+ _cfg: ClientConfig
178
+ _oauth_bearer_token_cache: OAuthBearerTokenCache | None = None
179
+ _session: requests.Session
58
180
 
59
- def __init__(self, *, base_url: str, auth_details: AuthDetails):
181
+ def __init__(
182
+ self,
183
+ *,
184
+ base_url: str,
185
+ auth_details: AuthDetailsAll,
186
+ config: ClientConfig | None = None,
187
+ ):
60
188
  self._auth_details = auth_details
61
189
  self._base_url = base_url
190
+ self._cfg = config or ClientConfig()
191
+ self._session = requests.Session()
192
+ self._session.verify = not self._cfg.allow_insecure_tls
193
+ self._file_uploader = FileUploader(
194
+ self._base_url,
195
+ self._auth_details,
196
+ self._cfg.allow_insecure_tls,
197
+ logger=self._cfg.logger,
198
+ )
199
+
200
+ def _get_response_json(
201
+ self, response: requests.Response, request_id: str
202
+ ) -> dict[str, JsonValue]:
203
+ if response.status_code < 200 or response.status_code > 299:
204
+ extra_details: dict[str, JsonValue] | None = None
205
+ try:
206
+ data = response.json()
207
+ extra_details = data
208
+ except JSONDecodeError:
209
+ extra_details = {
210
+ "body": response.text,
211
+ }
212
+ raise APIResponseError.construct_error(
213
+ status_code=response.status_code,
214
+ extra_details=extra_details,
215
+ request_id=request_id,
216
+ )
217
+ try:
218
+ return typing.cast(dict[str, JsonValue], response.json())
219
+ except JSONDecodeError as e:
220
+ raise SDKError("unable to process response", request_id=request_id) from e
221
+
222
+ def _send_request(self, request: requests.Request) -> requests.Response:
223
+ if self._cfg.extra_headers is not None:
224
+ request.headers = {**request.headers, **self._cfg.extra_headers}
225
+ if self._cfg.transform_request is not None:
226
+ request = self._cfg.transform_request(request)
227
+ prepared_request = request.prepare()
228
+ response = self._session.send(prepared_request)
229
+ return response
62
230
 
63
231
  def do_request(self, *, api_request: APIRequest, return_type: type[DT]) -> DT:
64
- http_request = self._build_http_request(api_request=api_request)
232
+ request_id = str(uuid4())
233
+ http_request = self._build_http_request(
234
+ api_request=api_request, request_id=request_id
235
+ )
65
236
  match http_request:
66
237
  case HTTPGetRequest():
67
- response = requests.get(
68
- http_request.url,
69
- headers=http_request.headers,
70
- params=http_request.query_params,
71
- )
238
+ request = requests.Request("GET", http_request.url)
239
+ request.params = http_request.query_params
72
240
  case HTTPPostRequest():
73
- response = requests.post(
74
- http_request.url,
75
- headers=http_request.headers,
76
- data=http_request.body,
77
- params=http_request.query_params,
78
- )
241
+ request = requests.Request("POST", http_request.url)
242
+ request.data = http_request.body
79
243
  case _:
80
244
  typing.assert_never(http_request)
81
- if response.status_code < 200 or response.status_code > 299:
82
- # TODO: handle_error
83
- pass
245
+ request.headers = http_request.headers
246
+ attributes: Attributes = {
247
+ "method": http_request.method,
248
+ "endpoint": api_request.endpoint,
249
+ }
250
+ with push_scope_optional(self._cfg.logger, "api_call", attributes=attributes):
251
+ if self._cfg.logger is not None:
252
+ self._cfg.logger.log_info(api_request.endpoint, attributes=attributes)
253
+ response = self._send_request(request)
254
+ response_data = self._get_response_json(response, request_id=request_id)
84
255
  cached_parser = self._get_cached_parser(return_type)
85
256
  try:
86
- data = response.json()["data"]
257
+ data = response_data["data"]
87
258
  return cached_parser.parse_api(data)
88
- except ValueError as err:
89
- # TODO: handle parse error
90
- raise err
259
+ except (ValueError, JSONDecodeError, KeyError) as e:
260
+ raise SDKError("unable to process response", request_id=request_id) from e
91
261
 
92
262
  def _get_cached_parser(self, data_type: type[DT]) -> CachedParser[DT]:
93
263
  if data_type not in self._parser_map:
94
264
  self._parser_map[data_type] = CachedParser(data_type)
95
265
  return self._parser_map[data_type]
96
266
 
267
+ def _get_oauth_bearer_token(self, *, oauth_details: AuthDetailsOAuth) -> str:
268
+ if (
269
+ self._oauth_bearer_token_cache is None
270
+ or (
271
+ self._oauth_bearer_token_cache.expires_at - datetime.now()
272
+ ).total_seconds()
273
+ < OAUTH_REFRESH_WINDOW_SECONDS
274
+ ):
275
+ refresh_url = urljoin(self._base_url, "/token/get_bearer_token")
276
+ request = requests.Request("POST", refresh_url)
277
+ request.data = {
278
+ "client_secret": oauth_details.refresh_token,
279
+ "scope": oauth_details.scope,
280
+ "grant_type": "client_credentials",
281
+ }
282
+ response = self._send_request(request)
283
+ data = self._get_response_json(response, request_id=str(uuid4()))
284
+ token_data = oauth_bearer_token_data_parser.parse_storage(data)
285
+ self._oauth_bearer_token_cache = OAuthBearerTokenCache(
286
+ token=token_data.access_token,
287
+ expires_at=datetime.now() + timedelta(seconds=token_data.expires_in),
288
+ )
289
+
290
+ return self._oauth_bearer_token_cache.token
291
+
97
292
  def _build_auth_headers(self) -> dict[str, str]:
98
293
  match self._auth_details:
99
294
  case AuthDetailsApiKey():
100
295
  encoded = base64.standard_b64encode(
101
- f"{self._auth_details.api_id}:{self._auth_details.api_secret_key}".encode(
102
- "utf-8"
103
- )
296
+ f"{self._auth_details.api_id}:{self._auth_details.api_secret_key}".encode()
104
297
  ).decode("utf-8")
105
298
  return {"Authorization": f"Basic {encoded}"}
299
+ case AuthDetailsOAuth():
300
+ token = self._get_oauth_bearer_token(oauth_details=self._auth_details)
301
+ return {"Authorization": f"Bearer {token}"}
106
302
  typing.assert_never(self._auth_details)
107
303
 
108
- def _build_http_request(self, *, api_request: APIRequest) -> HTTPRequest:
304
+ def _build_http_request(
305
+ self, *, api_request: APIRequest, request_id: str
306
+ ) -> HTTPRequest:
109
307
  headers = self._build_auth_headers()
308
+ headers[UNC_REQUEST_ID_HEADER] = request_id
309
+ headers[UNC_SDK_VERSION_HEADER] = get_version()
110
310
  method = api_request.method.lower()
111
- query_params = {"data": json.dumps(serialize_for_api(api_request.args))}
311
+ data = {"data": json.dumps(serialize_for_api(api_request.args))}
112
312
  match method:
113
313
  case "get":
114
314
  return HTTPGetRequest(
115
315
  method=EndpointMethod.GET,
116
316
  url=urljoin(self._base_url, api_request.endpoint),
117
- query_params=query_params,
317
+ query_params=data,
118
318
  headers=headers,
119
319
  )
120
320
  case "post":
121
321
  return HTTPPostRequest(
122
322
  method=EndpointMethod.POST,
123
323
  url=urljoin(self._base_url, api_request.endpoint),
324
+ body=data,
124
325
  headers=headers,
125
- query_params=query_params
126
326
  )
127
327
  case _:
128
328
  raise ValueError(f"unsupported request method: {method}")
329
+
330
+ def _get_downloaded_filename(self, *, cd: typing.Optional[str]) -> str:
331
+ if not cd:
332
+ return "Unknown"
333
+
334
+ fname = re.findall(r"filename\*=UTF-8''(.+)", cd)
335
+ if fname:
336
+ return unquote(fname[0])
337
+
338
+ fname = re.findall(r'filename="?(.+)"?', cd)
339
+ if fname:
340
+ return str(fname[0].strip('"'))
341
+
342
+ return "Unknown"
343
+
344
+ def download_files(
345
+ self, *, file_query: download_file_t.FileDownloadQuery
346
+ ) -> DownloadedFiles:
347
+ """Download a file from uncountable."""
348
+ request_id = str(uuid4())
349
+ api_request = APIRequest(
350
+ method=download_file_t.ENDPOINT_METHOD,
351
+ endpoint=download_file_t.ENDPOINT_PATH,
352
+ args=download_file_t.Arguments(
353
+ file_query=file_query,
354
+ ),
355
+ )
356
+ http_request = self._build_http_request(
357
+ api_request=api_request, request_id=request_id
358
+ )
359
+ request = requests.Request(http_request.method.value, http_request.url)
360
+ request.headers = http_request.headers
361
+ assert isinstance(http_request, HTTPGetRequest)
362
+ request.params = http_request.query_params
363
+ response = self._send_request(request)
364
+ content = response.content
365
+ content_disposition = response.headers.get("Content-Disposition", None)
366
+ return [
367
+ DownloadedFile(
368
+ name=self._get_downloaded_filename(cd=content_disposition),
369
+ size=len(content),
370
+ data=BytesIO(content),
371
+ )
372
+ ]
373
+
374
+ def upload_files(
375
+ self: typing.Self, *, file_uploads: list[FileUpload]
376
+ ) -> list[UploadedFile]:
377
+ """Upload files to uncountable, returning file ids that are usable with other SDK operations."""
378
+ return self._file_uploader.upload_files(file_uploads=file_uploads)
@@ -0,0 +1,41 @@
1
+ import functools
2
+ import os
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from uncountable.types import integration_server_t
6
+
7
+
8
+ @functools.cache
9
+ def get_version() -> str:
10
+ try:
11
+ version_str = version("UncountablePythonSDK")
12
+ except PackageNotFoundError:
13
+ version_str = "unknown"
14
+ return version_str
15
+
16
+
17
+ def get_server_env() -> str | None:
18
+ return os.environ.get("UNC_SERVER_ENV")
19
+
20
+
21
+ def get_webhook_server_port() -> int:
22
+ return int(os.environ.get("UNC_WEBHOOK_SERVER_PORT", "5001"))
23
+
24
+
25
+ def get_local_admin_server_port() -> int:
26
+ return int(os.environ.get("UNC_ADMIN_SERVER_PORT", "50051"))
27
+
28
+
29
+ def get_otel_enabled() -> bool:
30
+ return os.environ.get("UNC_OTEL_ENABLED") == "true"
31
+
32
+
33
+ def get_profiles_module() -> str:
34
+ return os.environ["UNC_PROFILES_MODULE"]
35
+
36
+
37
+ def get_integration_envs() -> list[integration_server_t.IntegrationEnvironment]:
38
+ return [
39
+ integration_server_t.IntegrationEnvironment(env)
40
+ for env in os.environ.get("UNC_INTEGRATION_ENVS", "prod").split(",")
41
+ ]
@@ -0,0 +1,135 @@
1
+ import asyncio
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ from typing import Generator, Literal, Self, assert_never
8
+
9
+ import aiohttp
10
+ import aiotus
11
+
12
+ from uncountable.integration.telemetry import Logger, push_scope_optional
13
+
14
+ from .types import AuthDetailsAll, AuthDetailsApiKey
15
+
16
+ _CHUNK_SIZE = 5 * 1024 * 1024 # s3 requires 5MiB minimum
17
+
18
+
19
+ class FileUploadType(StrEnum):
20
+ MEDIA_FILE_UPLOAD = "MEDIA_FILE_UPLOAD"
21
+ DATA_FILE_UPLOAD = "DATA_FILE_UPLOAD"
22
+
23
+
24
+ @dataclass(kw_only=True)
25
+ class MediaFileUpload:
26
+ """Upload file from a path on disk"""
27
+
28
+ path: str
29
+ type: Literal[FileUploadType.MEDIA_FILE_UPLOAD] = FileUploadType.MEDIA_FILE_UPLOAD
30
+
31
+
32
+ @dataclass(kw_only=True)
33
+ class DataFileUpload:
34
+ data: BytesIO
35
+ name: str
36
+ type: Literal[FileUploadType.DATA_FILE_UPLOAD] = FileUploadType.DATA_FILE_UPLOAD
37
+
38
+
39
+ FileUpload = MediaFileUpload | DataFileUpload
40
+
41
+
42
+ @dataclass(kw_only=True)
43
+ class FileBytes:
44
+ name: str
45
+ bytes_data: BytesIO
46
+
47
+
48
+ @contextmanager
49
+ def file_upload_data(file_upload: FileUpload) -> Generator[FileBytes, None, None]:
50
+ match file_upload:
51
+ case MediaFileUpload():
52
+ with open(file_upload.path, "rb") as f:
53
+ yield FileBytes(
54
+ name=Path(file_upload.path).name, bytes_data=BytesIO(f.read())
55
+ )
56
+ case DataFileUpload():
57
+ yield FileBytes(name=file_upload.name, bytes_data=file_upload.data)
58
+
59
+
60
+ @dataclass(kw_only=True)
61
+ class UploadedFile:
62
+ name: str
63
+ file_id: int
64
+
65
+
66
+ class UploadFailed(Exception):
67
+ pass
68
+
69
+
70
+ class FileUploader:
71
+ _auth_details: AuthDetailsAll
72
+ _base_url: str
73
+ _allow_insecure_tls: bool
74
+
75
+ def __init__(
76
+ self: Self,
77
+ base_url: str,
78
+ auth_details: AuthDetailsAll,
79
+ allow_insecure_tls: bool = False,
80
+ logger: Logger | None = None,
81
+ ) -> None:
82
+ self._base_url = base_url
83
+ self._auth_details = auth_details
84
+ self._allow_insecure_tls = allow_insecure_tls
85
+ self._logger = logger
86
+
87
+ async def _upload_file(self: Self, file_upload: FileUpload) -> UploadedFile:
88
+ creation_url = f"{self._base_url}/api/external/file_upload/files"
89
+ if not isinstance(self._auth_details, AuthDetailsApiKey):
90
+ raise NotImplementedError("Unsupported authentication method.")
91
+
92
+ auth = aiohttp.BasicAuth(
93
+ self._auth_details.api_id, self._auth_details.api_secret_key
94
+ )
95
+ async with (
96
+ aiohttp.ClientSession(
97
+ auth=auth, headers={"Origin": self._base_url}
98
+ ) as session,
99
+ ):
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
+ location = await aiotus.upload(
115
+ creation_url,
116
+ file_bytes.bytes_data,
117
+ {"filename": file_bytes.name.encode()},
118
+ client_session=session,
119
+ config=aiotus.RetryConfiguration(
120
+ ssl=not self._allow_insecure_tls
121
+ ),
122
+ chunksize=_CHUNK_SIZE,
123
+ )
124
+ if location is None:
125
+ raise UploadFailed(f"Failed to upload: {file_bytes.name}")
126
+ return UploadedFile(
127
+ name=file_bytes.name, file_id=int(location.path.split("/")[-1])
128
+ )
129
+
130
+ def upload_files(
131
+ self: Self, *, file_uploads: list[FileUpload]
132
+ ) -> list[UploadedFile]:
133
+ return [
134
+ asyncio.run(self._upload_file(file_upload)) for file_upload in file_uploads
135
+ ]
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(kw_only=True)
5
+ class AuthDetailsApiKey:
6
+ api_id: str
7
+ api_secret_key: str
8
+
9
+
10
+ @dataclass(kw_only=True)
11
+ class AuthDetailsOAuth:
12
+ refresh_token: str
13
+ scope: str = "unc.rnd"
14
+
15
+
16
+ AuthDetails = AuthDetailsApiKey # Legacy Mapping
17
+ AuthDetailsAll = AuthDetailsApiKey | AuthDetailsOAuth
File without changes
@@ -0,0 +1,49 @@
1
+ import argparse
2
+
3
+ from opentelemetry.trace import get_current_span
4
+
5
+ from uncountable.core.environment import get_local_admin_server_port
6
+ from uncountable.integration.queue_runner.command_server.command_client import (
7
+ send_job_queue_message,
8
+ )
9
+ from uncountable.integration.telemetry import Logger
10
+ from uncountable.types import queued_job_t
11
+
12
+
13
+ def main() -> None:
14
+ logger = Logger(get_current_span())
15
+
16
+ parser = argparse.ArgumentParser(
17
+ description="Process a job with a given command and job ID."
18
+ )
19
+
20
+ parser.add_argument(
21
+ "command",
22
+ type=str,
23
+ choices=["run"],
24
+ help="The command to execute (e.g., 'run')",
25
+ )
26
+
27
+ parser.add_argument("job_id", type=str, help="The ID of the job to process")
28
+
29
+ parser.add_argument(
30
+ "--host", type=str, default="localhost", nargs="?", help="The host to run on"
31
+ )
32
+
33
+ args = parser.parse_args()
34
+
35
+ 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()
47
+
48
+
49
+ main()