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,21 +1,25 @@
1
+ from __future__ import annotations
2
+
1
3
  import dataclasses
4
+ import datetime
2
5
  import math
3
6
  import types
4
7
  import typing
8
+ from abc import ABC, abstractmethod
5
9
  from collections import defaultdict
6
- from dataclasses import MISSING, dataclass
7
- from datetime import date, datetime
10
+ from datetime import date
8
11
  from decimal import Decimal
12
+ from enum import Enum, auto
9
13
  from importlib import resources
10
14
 
11
15
  import dateutil.parser
16
+ import msgspec.yaml
12
17
 
13
18
  from pkgs.serialization import (
14
19
  MissingSentryType,
15
20
  OpaqueKey,
16
21
  get_serial_class_data,
17
22
  get_serial_union_data,
18
- yaml,
19
23
  )
20
24
 
21
25
  from ._is_enum import is_string_enum_class
@@ -27,13 +31,41 @@ ParserFunction = typing.Callable[[typing.Any], T]
27
31
  ParserCache = dict[type[typing.Any], ParserFunction[typing.Any]]
28
32
 
29
33
 
30
- @dataclass(frozen=True, eq=True)
34
+ class SourceEncoding(Enum):
35
+ API = auto()
36
+ STORAGE = auto()
37
+
38
+
39
+ @dataclasses.dataclass(frozen=True, eq=True)
31
40
  class ParserOptions:
32
- convert_to_snake_case: bool
41
+ encoding: SourceEncoding
33
42
  strict_property_parsing: bool = False
34
43
 
44
+ @staticmethod
45
+ def Api(*, strict_property_parsing: bool = False) -> ParserOptions:
46
+ return ParserOptions(
47
+ encoding=SourceEncoding.API, strict_property_parsing=strict_property_parsing
48
+ )
49
+
50
+ @staticmethod
51
+ def Storage(*, strict_property_parsing: bool = False) -> ParserOptions:
52
+ return ParserOptions(
53
+ encoding=SourceEncoding.STORAGE,
54
+ strict_property_parsing=strict_property_parsing,
55
+ )
56
+
57
+ @property
58
+ def from_camel_case(self) -> bool:
59
+ return self.encoding == SourceEncoding.API
60
+
61
+ @property
62
+ def allow_direct_type(self) -> bool:
63
+ """This allows parsing from a DB column without having to check whether it's
64
+ the native format of the type, a JSON column, or a string encoding."""
65
+ return self.encoding == SourceEncoding.STORAGE
35
66
 
36
- @dataclass(frozen=True)
67
+
68
+ @dataclasses.dataclass(frozen=True)
37
69
  class ParserContext:
38
70
  options: ParserOptions
39
71
  cache: ParserCache
@@ -52,20 +84,38 @@ class ParserExtraFieldsError(ParserError):
52
84
  return f"extra fields were provided: {', '.join(self.extra_fields)}"
53
85
 
54
86
 
87
+ def is_union(field_type: typing.Any) -> bool:
88
+ origin = typing.get_origin(field_type)
89
+ return origin is typing.Union or origin is types.UnionType
90
+
91
+
55
92
  def is_optional(field_type: typing.Any) -> bool:
56
- return typing.get_origin(field_type) is typing.Union and type(
57
- None
58
- ) in typing.get_args(field_type)
93
+ return is_union(field_type) and type(None) in typing.get_args(field_type)
59
94
 
60
95
 
61
96
  def is_missing(field_type: typing.Any) -> bool:
62
- origin = typing.get_origin(field_type)
63
- if origin is not typing.Union:
97
+ if not is_union(field_type):
64
98
  return False
65
99
  args = typing.get_args(field_type)
66
100
  return not (len(args) == 0 or args[0] is not MissingSentryType)
67
101
 
68
102
 
103
+ def _has_field_default(field: dataclasses.Field[typing.Any]) -> bool:
104
+ return (
105
+ field.default != dataclasses.MISSING
106
+ and not isinstance(field.default, MissingSentryType)
107
+ ) or field.default_factory != dataclasses.MISSING
108
+
109
+
110
+ def _get_field_default(
111
+ field: dataclasses.Field[typing.Any],
112
+ ) -> typing.Any:
113
+ if field.default != dataclasses.MISSING:
114
+ return field.default
115
+ assert field.default_factory != dataclasses.MISSING
116
+ return field.default_factory()
117
+
118
+
69
119
  def _invoke_tuple_parsers(
70
120
  tuple_type: type[T],
71
121
  arg_parsers: typing.Sequence[typing.Callable[[typing.Any], object]],
@@ -118,11 +168,39 @@ def _invoke_membership_parser(
118
168
  raise ValueError(f"Expected value from {expected_values} but got value {value}")
119
169
 
120
170
 
171
+ # Uses `is` to compare
172
+ def _build_identity_parser(
173
+ identity_value: T,
174
+ ) -> ParserFunction[T]:
175
+ def parse(value: typing.Any) -> T:
176
+ if value is identity_value:
177
+ return identity_value
178
+ raise ValueError(
179
+ f"Expected value {identity_value} (type: {type(identity_value)}) but got value {value} (type: {type(value)})"
180
+ )
181
+
182
+ return parse
183
+
184
+
185
+ NONE_IDENTITY_PARSER = _build_identity_parser(None)
186
+
187
+
121
188
  def _build_parser_discriminated_union(
122
- discriminator: str, discriminator_map: dict[str, ParserFunction[T]]
189
+ context: ParserContext,
190
+ discriminator_raw: str,
191
+ discriminator_map: dict[str, ParserFunction[T]],
123
192
  ) -> ParserFunction[T]:
193
+ discriminator = (
194
+ snake_to_camel_case(discriminator_raw)
195
+ if context.options.from_camel_case
196
+ else discriminator_raw
197
+ )
198
+
124
199
  def parse(value: typing.Any) -> typing.Any:
125
- discriminant = value.get(discriminator)
200
+ if context.options.allow_direct_type and dataclasses.is_dataclass(value):
201
+ discriminant = getattr(value, discriminator)
202
+ else:
203
+ discriminant = value.get(discriminator)
126
204
  if discriminant is None:
127
205
  raise ValueError("missing-union-discriminant")
128
206
  if not isinstance(discriminant, str):
@@ -138,20 +216,10 @@ def _build_parser_discriminated_union(
138
216
  def _build_parser_inner(
139
217
  parsed_type: type[T],
140
218
  context: ParserContext,
141
- *,
142
- convert_string_to_snake_case: bool = False,
143
219
  ) -> ParserFunction[T]:
144
220
  """
145
- convert_to_snake_case - internal flag
146
- if convert_to_snake_case is True, and parsed_type is str,
147
- then the generated parser will convert camel to snake case case
148
- should only be True for cases like dictionary keys
149
- should only be True if options.convert_to_snake_case is True
150
-
151
- NOTE: This argument makes caching at this level difficult, as the cache-map
152
- would need to vary based on this argument. For this reason only dataclasses
153
- are cached now, as they don't use the argument, and they're known to be safe.
154
- This is also enough to support some recursion.
221
+ IMPROVE: We can now cache at this level, to avoid producing redundant
222
+ internal parsers.
155
223
  """
156
224
 
157
225
  serial_union = get_serial_union_data(parsed_type)
@@ -163,6 +231,7 @@ def _build_parser_inner(
163
231
  parsed_type = serial_union.get_union_underlying()
164
232
  else:
165
233
  return _build_parser_discriminated_union(
234
+ context,
166
235
  discriminator,
167
236
  {
168
237
  key: _build_parser_inner(value, context)
@@ -171,7 +240,7 @@ def _build_parser_inner(
171
240
  )
172
241
 
173
242
  if dataclasses.is_dataclass(parsed_type):
174
- return _build_parser_dataclass(parsed_type, context) # type: ignore[arg-type]
243
+ return _build_parser_dataclass(parsed_type, context)
175
244
 
176
245
  # namedtuple support
177
246
  if is_namedtuple_type(parsed_type):
@@ -184,15 +253,17 @@ def _build_parser_inner(
184
253
  field_name: field_parser(
185
254
  value.get(
186
255
  snake_to_camel_case(field_name)
187
- if context.options.convert_to_snake_case
256
+ if context.options.from_camel_case
188
257
  else field_name
189
258
  )
190
259
  )
191
260
  for field_name, field_parser in field_parsers
192
261
  })
193
262
 
263
+ # IMPROVE: unclear why we need == here
194
264
  if parsed_type == type(None): # noqa: E721
195
- return lambda value: _invoke_membership_parser({None}, value) # type: ignore
265
+ # Need to convince type checker that parsed_type is type(None)
266
+ return typing.cast(ParserFunction[T], NONE_IDENTITY_PARSER)
196
267
 
197
268
  origin = typing.get_origin(parsed_type)
198
269
  if origin is tuple:
@@ -219,7 +290,7 @@ def _build_parser_inner(
219
290
  arg_parsers = [_build_parser_inner(arg, context) for arg in sorted_args]
220
291
  return lambda value: _invoke_fallback_parsers(parsed_type, arg_parsers, value)
221
292
 
222
- if parsed_type is typing.Any: # type: ignore[comparison-overlap]
293
+ if parsed_type is typing.Any:
223
294
  return lambda value: value
224
295
 
225
296
  if origin in (list, set):
@@ -245,23 +316,32 @@ def _build_parser_inner(
245
316
  args = typing.get_args(parsed_type)
246
317
  if len(args) != 2:
247
318
  raise ValueError("Dict types only support two arguments for now")
248
- k_parser = _build_parser_inner(
319
+ k_inner_parser = _build_parser_inner(
249
320
  args[0],
250
321
  context,
251
- convert_string_to_snake_case=context.options.convert_to_snake_case,
252
322
  )
323
+
324
+ def key_parser(value: typing.Any) -> object:
325
+ inner = k_inner_parser(value)
326
+ if (
327
+ isinstance(inner, str)
328
+ # enum keys and OpaqueData's would also have string value types,
329
+ # but their explicit type is not a string, thus shouldn't be converted
330
+ and args[0] is str
331
+ and context.options.from_camel_case
332
+ ):
333
+ return camel_to_snake_case(value)
334
+ return inner
335
+
253
336
  v_parser = _build_parser_inner(args[1], context)
254
337
  return lambda value: origin(
255
- (k_parser(k), v_parser(v)) for k, v in value.items()
338
+ (key_parser(k), v_parser(v)) for k, v in value.items()
256
339
  )
257
340
 
258
341
  if origin == typing.Literal:
259
342
  valid_values: set[T] = set(typing.get_args(parsed_type))
260
343
  return lambda value: _invoke_membership_parser(valid_values, value)
261
344
 
262
- if parsed_type is str and convert_string_to_snake_case:
263
- return lambda value: camel_to_snake_case(value) # type: ignore
264
-
265
345
  if parsed_type is int:
266
346
  # first parse ints to decimal to allow scientific notation and decimals
267
347
  # e.g. (1) 1e4 => 1000, (2) 3.0 => 3
@@ -281,11 +361,25 @@ def _build_parser_inner(
281
361
 
282
362
  return parse_int
283
363
 
284
- if parsed_type is datetime:
285
- return lambda value: dateutil.parser.isoparse(value) # type:ignore
364
+ if parsed_type is datetime.datetime:
365
+
366
+ def parse_datetime(value: typing.Any) -> T:
367
+ if context.options.allow_direct_type and isinstance(
368
+ value, datetime.datetime
369
+ ):
370
+ return value # type: ignore
371
+ return dateutil.parser.isoparse(value) # type:ignore
372
+
373
+ return parse_datetime
286
374
 
287
375
  if parsed_type is date:
288
- return lambda value: date.fromisoformat(value) # type:ignore
376
+
377
+ def parse_date(value: typing.Any) -> T:
378
+ if context.options.allow_direct_type and isinstance(value, date):
379
+ return value # type:ignore
380
+ return date.fromisoformat(value) # type:ignore
381
+
382
+ return parse_date
289
383
 
290
384
  # MyPy: It's unclear why `parsed_type in (str, OpaqueKey)` is flagged as invalid
291
385
  # Thus an or statement is used instead, which isn't flagged as invalid.
@@ -320,7 +414,17 @@ def _build_parser_inner(
320
414
  raise ValueError("Missing type cannot be parsed directly")
321
415
 
322
416
  return error
323
- raise ValueError(f"Unhandled type {parsed_type}")
417
+
418
+ # Check last for generic annotated types and process them unwrapped
419
+ # this must be last, since some of the expected types, like Unions,
420
+ # will also be annotated, but have a special form
421
+ if typing.get_origin(parsed_type) is typing.Annotated:
422
+ return _build_parser_inner(
423
+ parsed_type.__origin__, # type: ignore[attr-defined]
424
+ context,
425
+ )
426
+
427
+ raise ValueError(f"Unhandled type {parsed_type}/{origin}")
324
428
 
325
429
 
326
430
  def _build_parser_dataclass(
@@ -335,8 +439,7 @@ def _build_parser_dataclass(
335
439
  cur_parser = context.cache.get(parsed_type)
336
440
  if cur_parser is not None:
337
441
  return cur_parser
338
-
339
- type_hints = typing.get_type_hints(parsed_type)
442
+ type_hints = typing.get_type_hints(parsed_type, include_extras=True)
340
443
  dc_field_parsers: list[
341
444
  tuple[
342
445
  dataclasses.Field[typing.Any],
@@ -351,29 +454,31 @@ def _build_parser_dataclass(
351
454
  return (
352
455
  snake_to_camel_case(field_name)
353
456
  if (
354
- context.options.convert_to_snake_case
457
+ context.options.from_camel_case
355
458
  and not serial_class_data.has_unconverted_key(field_name)
356
459
  )
357
460
  else field_name
358
461
  )
359
462
 
360
463
  def parse(value: typing.Any) -> typing.Any:
464
+ # Use an exact type match to prevent base/derived class mismatches
465
+ if context.options.allow_direct_type and type(value) is parsed_type:
466
+ return value
467
+
361
468
  data: dict[typing.Any, typing.Any] = {}
362
469
  for field, field_type, field_parser in dc_field_parsers:
363
470
  field_raw_value = None
364
471
  try:
365
472
  field_raw_value = value.get(
366
473
  resolve_serialized_field_name(field_name=field.name),
367
- MISSING,
474
+ dataclasses.MISSING,
368
475
  )
369
476
  field_value: typing.Any
370
- if field_raw_value == MISSING:
477
+ if field_raw_value == dataclasses.MISSING:
371
478
  if serial_class_data.has_parse_require(field.name):
372
479
  raise ValueError("missing-required-field", field.name)
373
- if field.default != MISSING:
374
- field_value = field.default
375
- elif field.default_factory != MISSING:
376
- field_value = field.default_factory()
480
+ if _has_field_default(field):
481
+ field_value = _get_field_default(field)
377
482
  elif is_missing(field_type):
378
483
  field_value = MissingSentryType()
379
484
  elif is_optional(field_type):
@@ -385,6 +490,13 @@ def _build_parser_dataclass(
385
490
  field_value = False
386
491
  else:
387
492
  raise ValueError("missing-value-for-field", field.name)
493
+ elif (
494
+ field_raw_value is None
495
+ and not is_optional(field_type)
496
+ and _has_field_default(field)
497
+ and not serial_class_data.has_parse_require(field.name)
498
+ ):
499
+ field_value = _get_field_default(field)
388
500
  elif serial_class_data.has_unconverted_value(field.name):
389
501
  field_value = field_raw_value
390
502
  else:
@@ -452,15 +564,46 @@ def build_parser(
452
564
  return built_parser
453
565
 
454
566
 
455
- class CachedParser(typing.Generic[T]):
567
+ class ParserBase(ABC, typing.Generic[T]):
568
+ def parse_from_encoding(
569
+ self,
570
+ args: typing.Any,
571
+ *,
572
+ source_encoding: SourceEncoding,
573
+ ) -> T:
574
+ match source_encoding:
575
+ case SourceEncoding.API:
576
+ return self.parse_api(args)
577
+ case SourceEncoding.STORAGE:
578
+ return self.parse_storage(args)
579
+ case _:
580
+ typing.assert_never(source_encoding)
581
+
582
+ # IMPROVE: Args would be better typed as "object"
583
+ @abstractmethod
584
+ def parse_storage(self, args: typing.Any) -> T: ...
585
+
586
+ @abstractmethod
587
+ def parse_api(self, args: typing.Any) -> T: ...
588
+
589
+ def parse_yaml_file(self, path: str) -> T:
590
+ with open(path, encoding="utf-8") as data_in:
591
+ return self.parse_storage(msgspec.yaml.decode(data_in.read()))
592
+
593
+ def parse_yaml_resource(self, package: resources.Package, resource: str) -> T:
594
+ with resources.open_text(package, resource) as fp:
595
+ return self.parse_storage(msgspec.yaml.decode(fp.read()))
596
+
597
+
598
+ class CachedParser(ParserBase[T], typing.Generic[T]):
456
599
  def __init__(
457
600
  self,
458
601
  args: type[T],
459
602
  strict_property_parsing: bool = False,
460
603
  ):
461
604
  self.arguments = args
462
- self.parser_api: typing.Optional[ParserFunction[T]] = None
463
- self.parser_storage: typing.Optional[ParserFunction[T]] = None
605
+ self.parser_api: ParserFunction[T] | None = None
606
+ self.parser_storage: ParserFunction[T] | None = None
464
607
  self.strict_property_parsing = strict_property_parsing
465
608
 
466
609
  def parse_api(self, args: typing.Any) -> T:
@@ -474,8 +617,7 @@ class CachedParser(typing.Generic[T]):
474
617
  if self.parser_api is None:
475
618
  self.parser_api = build_parser(
476
619
  self.arguments,
477
- ParserOptions(
478
- convert_to_snake_case=True,
620
+ ParserOptions.Api(
479
621
  strict_property_parsing=self.strict_property_parsing,
480
622
  ),
481
623
  )
@@ -489,18 +631,9 @@ class CachedParser(typing.Generic[T]):
489
631
  if self.parser_storage is None:
490
632
  self.parser_storage = build_parser(
491
633
  self.arguments,
492
- ParserOptions(
493
- convert_to_snake_case=False,
634
+ ParserOptions.Storage(
494
635
  strict_property_parsing=self.strict_property_parsing,
495
636
  ),
496
637
  )
497
638
  assert self.parser_storage is not None
498
639
  return self.parser_storage(args)
499
-
500
- def parse_yaml_file(self, path: str) -> T:
501
- with open(path, encoding="utf-8") as data_in:
502
- return self.parse_storage(yaml.safe_load(data_in))
503
-
504
- def parse_yaml_resource(self, package: resources.Package, resource: str) -> T:
505
- with resources.open_text(package, resource) as fp:
506
- return self.parse_storage(yaml.safe_load(fp))
@@ -17,3 +17,4 @@ from .file_type_utils import FileSystemSFTPConfig as FileSystemSFTPConfig
17
17
  from .file_type_utils import FileTransfer as FileTransfer
18
18
  from .file_type_utils import IncompatibleFileReference as IncompatibleFileReference
19
19
  from .file_type_utils import RemoteObjectReference as RemoteObjectReference
20
+ from .filesystem_session import FileSystemSession as FileSystemSession
@@ -0,0 +1,144 @@
1
+ from io import BytesIO
2
+
3
+ from azure.core.credentials import AzureSasCredential
4
+ from azure.storage.blob import BlobServiceClient, ContainerClient
5
+
6
+ from pkgs.filesystem_utils.file_type_utils import (
7
+ FileObjectData,
8
+ FileSystemBlobConfig,
9
+ FileSystemFileReference,
10
+ FileSystemObject,
11
+ FileTransfer,
12
+ IncompatibleFileReference,
13
+ )
14
+
15
+ from .filesystem_session import FileSystemSession
16
+
17
+
18
+ def _add_slash(prefix: str) -> str:
19
+ if len(prefix) > 0 and prefix[-1] != "/":
20
+ prefix = prefix + "/"
21
+ return prefix
22
+
23
+
24
+ class BlobSession(FileSystemSession):
25
+ config: FileSystemBlobConfig
26
+
27
+ def __init__(self, blob_config: FileSystemBlobConfig) -> None:
28
+ super().__init__()
29
+ self.config = blob_config
30
+
31
+ def start(self) -> None:
32
+ self.service_client: BlobServiceClient | None = BlobServiceClient(
33
+ self.config.account_url, credential=self.config.credential
34
+ )
35
+ self.container_client: ContainerClient | None = (
36
+ self.service_client.get_container_client(self.config.container)
37
+ )
38
+
39
+ def __enter__(self) -> "BlobSession":
40
+ self.start()
41
+ return self
42
+
43
+ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
44
+ self.service_client = None
45
+ self.container_client = None
46
+
47
+ def list_files(
48
+ self,
49
+ dir_path: FileSystemObject,
50
+ *,
51
+ recursive: bool = False,
52
+ valid_extensions: list[str] | None = None,
53
+ ) -> list[FileSystemObject]:
54
+ if not isinstance(dir_path, FileSystemFileReference):
55
+ raise IncompatibleFileReference()
56
+
57
+ assert self.service_client is not None and self.container_client is not None, (
58
+ "call to list_files on uninitialized blob session"
59
+ )
60
+
61
+ filesystem_file_references: list[FileSystemObject] = []
62
+ prefix = _add_slash(dir_path.filepath)
63
+ for blob in self.container_client.list_blobs(name_starts_with=prefix):
64
+ if not recursive and (
65
+ blob.name == prefix or "/" in blob.name[len(prefix) :]
66
+ ):
67
+ continue
68
+ if valid_extensions is None or any(
69
+ blob.name.endswith(valid_extension)
70
+ for valid_extension in valid_extensions
71
+ ):
72
+ filesystem_file_references.append(
73
+ FileSystemFileReference(
74
+ filepath=blob.name,
75
+ )
76
+ )
77
+
78
+ return filesystem_file_references
79
+
80
+ def download_files(
81
+ self,
82
+ filepaths: list[FileSystemObject],
83
+ ) -> list[FileObjectData]:
84
+ downloaded_files: list[FileObjectData] = []
85
+ assert self.service_client is not None and self.container_client is not None, (
86
+ "call to download_files on uninitialized blob session"
87
+ )
88
+
89
+ for file_object in filepaths:
90
+ if (
91
+ not isinstance(file_object, FileSystemFileReference)
92
+ or file_object.filename is None
93
+ ):
94
+ raise IncompatibleFileReference()
95
+
96
+ blob_client = self.container_client.get_blob_client(file_object.filepath)
97
+ download_stream = blob_client.download_blob()
98
+ file_data = download_stream.readall()
99
+ downloaded_files.append(
100
+ FileObjectData(
101
+ file_data=file_data,
102
+ file_IO=BytesIO(file_data),
103
+ filename=file_object.filename,
104
+ filepath=file_object.filepath,
105
+ )
106
+ )
107
+
108
+ return downloaded_files
109
+
110
+ def move_files(self, file_mappings: list[FileTransfer]) -> None:
111
+ assert self.service_client is not None and self.container_client is not None, (
112
+ "call to move_files on uninitialized blob session"
113
+ )
114
+
115
+ for src_file, dest_file in file_mappings:
116
+ if not isinstance(src_file, FileSystemFileReference) or not isinstance(
117
+ dest_file, FileSystemFileReference
118
+ ):
119
+ raise IncompatibleFileReference()
120
+
121
+ source_blob_client = self.container_client.get_blob_client(
122
+ src_file.filepath
123
+ )
124
+ dest_blob_client = self.container_client.get_blob_client(dest_file.filepath)
125
+
126
+ source_url = (
127
+ f"{source_blob_client.url}?{self.config.credential.signature}"
128
+ if isinstance(self.config.credential, AzureSasCredential)
129
+ else source_blob_client.url
130
+ )
131
+
132
+ dest_blob_client.start_copy_from_url(source_url)
133
+ source_blob_client.delete_blob()
134
+
135
+ def delete_files(self, filepaths: list[FileSystemObject]) -> None:
136
+ assert self.service_client is not None and self.container_client is not None, (
137
+ "call to delete_files on uninitialized blob session"
138
+ )
139
+ for file_object in filepaths:
140
+ if not isinstance(file_object, FileSystemFileReference):
141
+ raise IncompatibleFileReference()
142
+
143
+ blob_client = self.container_client.get_blob_client(file_object.filepath)
144
+ blob_client.delete_blob()
@@ -1,6 +1,6 @@
1
1
  import os
2
2
  from io import BytesIO
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
 
5
5
  from google.oauth2 import service_account
6
6
  from googleapiclient.discovery import build as build_gdrive_connection
@@ -30,7 +30,7 @@ def download_gdrive_file(
30
30
  mime_type: str,
31
31
  *,
32
32
  verbose: bool = False,
33
- ) -> Optional[FileObjectData]:
33
+ ) -> FileObjectData | None:
34
34
  if "folder" in mime_type:
35
35
  if verbose:
36
36
  print(f"{filename} is a folder and will not be downloaded.")
@@ -63,7 +63,7 @@ def download_gdrive_file(
63
63
  downloader = MediaIoBaseDownload(file_handler, file_request)
64
64
  download_complete = False
65
65
  while not download_complete:
66
- status, download_complete = downloader.next_chunk()
66
+ _status, download_complete = downloader.next_chunk()
67
67
 
68
68
  file_handler.seek(0)
69
69
  file_data = file_handler.read()
@@ -148,7 +148,7 @@ def move_gdrive_file(
148
148
  src_file_id: str,
149
149
  dest_folder_id: str,
150
150
  *,
151
- dest_filename: Optional[str] = None,
151
+ dest_filename: str | None = None,
152
152
  ) -> None:
153
153
  # Retrieve the existing parents to remove
154
154
  file = (
@@ -197,7 +197,7 @@ class GDriveSession(FileSystemSession):
197
197
  dir_path: FileSystemObject,
198
198
  *,
199
199
  recursive: bool = False,
200
- valid_file_extensions: Optional[tuple[str, ...]] = None,
200
+ valid_file_extensions: tuple[str, ...] | None = None,
201
201
  ) -> list[FileSystemObject]:
202
202
  if not isinstance(dir_path, RemoteObjectReference):
203
203
  raise IncompatibleFileReference(
@@ -1,6 +1,7 @@
1
1
  from io import BytesIO
2
2
 
3
3
  from boto3.session import Session
4
+ from mypy_boto3_s3.service_resource import Bucket
4
5
 
5
6
  from pkgs.filesystem_utils.file_type_utils import (
6
7
  FileObjectData,
@@ -37,7 +38,7 @@ class S3Session(FileSystemSession):
37
38
  aws_session_token=self.config.session_token,
38
39
  )
39
40
 
40
- self.bucket = s3_resource.Bucket(self.config.bucket_name)
41
+ self.bucket: Bucket | None = s3_resource.Bucket(self.config.bucket_name)
41
42
 
42
43
  def __enter__(self) -> "S3Session":
43
44
  self.start()
@@ -1,7 +1,6 @@
1
1
  import os
2
2
  from collections.abc import Iterable
3
3
  from io import BytesIO
4
- from typing import Optional
5
4
 
6
5
  import paramiko
7
6
  import pysftp
@@ -30,8 +29,8 @@ def list_sftp_files(
30
29
  connection: pysftp.Connection,
31
30
  dir_path: str,
32
31
  *,
33
- valid_extensions: Optional[Iterable[str]] = None,
34
- parent_dir_path: Optional[str] = None,
32
+ valid_extensions: Iterable[str] | None = None,
33
+ parent_dir_path: str | None = None,
35
34
  recursive: bool = True,
36
35
  ) -> list[str]:
37
36
  file_paths: list[str] = []
@@ -55,6 +54,10 @@ def list_sftp_files(
55
54
  os.path.join(dir_path, file)
56
55
  for file in connection.listdir(dir_path)
57
56
  if connection.isfile(os.path.join(dir_path, file))
57
+ and (
58
+ valid_extensions is None
59
+ or os.path.splitext(file)[1] in valid_extensions
60
+ )
58
61
  ])
59
62
  return file_paths
60
63