agenta 0.26.0a0__py3-none-any.whl → 0.27.0__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 agenta might be problematic. Click here for more details.

Files changed (85) hide show
  1. agenta/__init__.py +29 -10
  2. agenta/cli/helper.py +5 -1
  3. agenta/client/backend/__init__.py +14 -0
  4. agenta/client/backend/apps/client.py +28 -20
  5. agenta/client/backend/client.py +47 -16
  6. agenta/client/backend/containers/client.py +5 -1
  7. agenta/client/backend/core/__init__.py +2 -1
  8. agenta/client/backend/core/client_wrapper.py +6 -6
  9. agenta/client/backend/core/file.py +33 -11
  10. agenta/client/backend/core/http_client.py +45 -31
  11. agenta/client/backend/core/pydantic_utilities.py +144 -29
  12. agenta/client/backend/core/request_options.py +3 -0
  13. agenta/client/backend/core/serialization.py +139 -42
  14. agenta/client/backend/evaluations/client.py +7 -2
  15. agenta/client/backend/evaluators/client.py +349 -1
  16. agenta/client/backend/observability/client.py +11 -2
  17. agenta/client/backend/testsets/client.py +10 -10
  18. agenta/client/backend/types/__init__.py +14 -0
  19. agenta/client/backend/types/app.py +1 -0
  20. agenta/client/backend/types/app_variant_response.py +3 -1
  21. agenta/client/backend/types/config_dto.py +32 -0
  22. agenta/client/backend/types/config_response_model.py +32 -0
  23. agenta/client/backend/types/create_span.py +3 -2
  24. agenta/client/backend/types/environment_output.py +1 -0
  25. agenta/client/backend/types/environment_output_extended.py +1 -0
  26. agenta/client/backend/types/evaluation.py +1 -2
  27. agenta/client/backend/types/evaluator.py +2 -0
  28. agenta/client/backend/types/evaluator_config.py +1 -0
  29. agenta/client/backend/types/evaluator_mapping_output_interface.py +21 -0
  30. agenta/client/backend/types/evaluator_output_interface.py +21 -0
  31. agenta/client/backend/types/human_evaluation.py +1 -2
  32. agenta/client/backend/types/lifecycle_dto.py +24 -0
  33. agenta/client/backend/types/llm_tokens.py +2 -2
  34. agenta/client/backend/types/reference_dto.py +23 -0
  35. agenta/client/backend/types/reference_request_model.py +23 -0
  36. agenta/client/backend/types/span.py +1 -0
  37. agenta/client/backend/types/span_detail.py +7 -1
  38. agenta/client/backend/types/test_set_output_response.py +5 -2
  39. agenta/client/backend/types/trace_detail.py +7 -1
  40. agenta/client/backend/types/with_pagination.py +4 -2
  41. agenta/client/backend/variants/client.py +1565 -272
  42. agenta/docker/docker-assets/Dockerfile.cloud.template +1 -1
  43. agenta/sdk/__init__.py +44 -7
  44. agenta/sdk/agenta_init.py +85 -33
  45. agenta/sdk/context/__init__.py +0 -0
  46. agenta/sdk/context/routing.py +26 -0
  47. agenta/sdk/context/tracing.py +3 -0
  48. agenta/sdk/decorators/__init__.py +0 -0
  49. agenta/sdk/decorators/{llm_entrypoint.py → routing.py} +216 -191
  50. agenta/sdk/decorators/tracing.py +218 -99
  51. agenta/sdk/litellm/__init__.py +1 -0
  52. agenta/sdk/litellm/litellm.py +288 -0
  53. agenta/sdk/managers/__init__.py +6 -0
  54. agenta/sdk/managers/config.py +318 -0
  55. agenta/sdk/managers/deployment.py +45 -0
  56. agenta/sdk/managers/shared.py +639 -0
  57. agenta/sdk/managers/variant.py +182 -0
  58. agenta/sdk/router.py +0 -7
  59. agenta/sdk/tracing/__init__.py +1 -0
  60. agenta/sdk/tracing/attributes.py +141 -0
  61. agenta/sdk/tracing/context.py +24 -0
  62. agenta/sdk/tracing/conventions.py +49 -0
  63. agenta/sdk/tracing/exporters.py +65 -0
  64. agenta/sdk/tracing/inline.py +1252 -0
  65. agenta/sdk/tracing/processors.py +117 -0
  66. agenta/sdk/tracing/spans.py +136 -0
  67. agenta/sdk/tracing/tracing.py +233 -0
  68. agenta/sdk/types.py +49 -2
  69. agenta/sdk/utils/{helper/openai_cost.py → costs.py} +3 -0
  70. agenta/sdk/utils/debug.py +5 -5
  71. agenta/sdk/utils/exceptions.py +52 -0
  72. agenta/sdk/utils/globals.py +3 -5
  73. agenta/sdk/{tracing/logger.py → utils/logging.py} +3 -5
  74. agenta/sdk/utils/singleton.py +13 -0
  75. {agenta-0.26.0a0.dist-info → agenta-0.27.0.dist-info}/METADATA +5 -1
  76. {agenta-0.26.0a0.dist-info → agenta-0.27.0.dist-info}/RECORD +78 -57
  77. agenta/sdk/config_manager.py +0 -205
  78. agenta/sdk/context.py +0 -41
  79. agenta/sdk/decorators/base.py +0 -10
  80. agenta/sdk/tracing/callbacks.py +0 -187
  81. agenta/sdk/tracing/llm_tracing.py +0 -617
  82. agenta/sdk/tracing/tasks_manager.py +0 -129
  83. agenta/sdk/tracing/tracing_context.py +0 -27
  84. {agenta-0.26.0a0.dist-info → agenta-0.27.0.dist-info}/WHEEL +0 -0
  85. {agenta-0.26.0a0.dist-info → agenta-0.27.0.dist-info}/entry_points.txt +0 -0
@@ -148,9 +148,9 @@ def get_request_body(
148
148
  json_body = maybe_filter_request_body(json, request_options, omit)
149
149
 
150
150
  # If you have an empty JSON body, you should just send None
151
- return (
152
- json_body if json_body != {} else None
153
- ), data_body if data_body != {} else None
151
+ return (json_body if json_body != {} else None), (
152
+ data_body if data_body != {} else None
153
+ )
154
154
 
155
155
 
156
156
  class HttpClient:
@@ -158,9 +158,9 @@ class HttpClient:
158
158
  self,
159
159
  *,
160
160
  httpx_client: httpx.Client,
161
- base_timeout: typing.Optional[float],
162
- base_headers: typing.Dict[str, str],
163
- base_url: typing.Optional[str] = None,
161
+ base_timeout: typing.Callable[[], typing.Optional[float]],
162
+ base_headers: typing.Callable[[], typing.Dict[str, str]],
163
+ base_url: typing.Optional[typing.Callable[[], str]] = None,
164
164
  ):
165
165
  self.base_url = base_url
166
166
  self.base_timeout = base_timeout
@@ -168,7 +168,10 @@ class HttpClient:
168
168
  self.httpx_client = httpx_client
169
169
 
170
170
  def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str:
171
- base_url = self.base_url if maybe_base_url is None else maybe_base_url
171
+ base_url = maybe_base_url
172
+ if self.base_url is not None and base_url is None:
173
+ base_url = self.base_url()
174
+
172
175
  if base_url is None:
173
176
  raise ValueError(
174
177
  "A base_url is required to make this request, please provide one and try again."
@@ -200,7 +203,7 @@ class HttpClient:
200
203
  request_options.get("timeout_in_seconds")
201
204
  if request_options is not None
202
205
  and request_options.get("timeout_in_seconds") is not None
203
- else self.base_timeout
206
+ else self.base_timeout()
204
207
  )
205
208
 
206
209
  json_body, data_body = get_request_body(
@@ -213,7 +216,7 @@ class HttpClient:
213
216
  headers=jsonable_encoder(
214
217
  remove_none_from_dict(
215
218
  {
216
- **self.base_headers,
219
+ **self.base_headers(),
217
220
  **(headers if headers is not None else {}),
218
221
  **(
219
222
  request_options.get("additional_headers", {}) or {}
@@ -246,9 +249,11 @@ class HttpClient:
246
249
  json=json_body,
247
250
  data=data_body,
248
251
  content=content,
249
- files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
250
- if files is not None
251
- else None,
252
+ files=(
253
+ convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
254
+ if (files is not None and files is not omit)
255
+ else None
256
+ ),
252
257
  timeout=timeout,
253
258
  )
254
259
 
@@ -300,7 +305,7 @@ class HttpClient:
300
305
  request_options.get("timeout_in_seconds")
301
306
  if request_options is not None
302
307
  and request_options.get("timeout_in_seconds") is not None
303
- else self.base_timeout
308
+ else self.base_timeout()
304
309
  )
305
310
 
306
311
  json_body, data_body = get_request_body(
@@ -313,7 +318,7 @@ class HttpClient:
313
318
  headers=jsonable_encoder(
314
319
  remove_none_from_dict(
315
320
  {
316
- **self.base_headers,
321
+ **self.base_headers(),
317
322
  **(headers if headers is not None else {}),
318
323
  **(
319
324
  request_options.get("additional_headers", {})
@@ -345,9 +350,11 @@ class HttpClient:
345
350
  json=json_body,
346
351
  data=data_body,
347
352
  content=content,
348
- files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
349
- if files is not None
350
- else None,
353
+ files=(
354
+ convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
355
+ if (files is not None and files is not omit)
356
+ else None
357
+ ),
351
358
  timeout=timeout,
352
359
  ) as stream:
353
360
  yield stream
@@ -358,9 +365,9 @@ class AsyncHttpClient:
358
365
  self,
359
366
  *,
360
367
  httpx_client: httpx.AsyncClient,
361
- base_timeout: typing.Optional[float],
362
- base_headers: typing.Dict[str, str],
363
- base_url: typing.Optional[str] = None,
368
+ base_timeout: typing.Callable[[], typing.Optional[float]],
369
+ base_headers: typing.Callable[[], typing.Dict[str, str]],
370
+ base_url: typing.Optional[typing.Callable[[], str]] = None,
364
371
  ):
365
372
  self.base_url = base_url
366
373
  self.base_timeout = base_timeout
@@ -368,7 +375,10 @@ class AsyncHttpClient:
368
375
  self.httpx_client = httpx_client
369
376
 
370
377
  def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str:
371
- base_url = self.base_url if maybe_base_url is None else maybe_base_url
378
+ base_url = maybe_base_url
379
+ if self.base_url is not None and base_url is None:
380
+ base_url = self.base_url()
381
+
372
382
  if base_url is None:
373
383
  raise ValueError(
374
384
  "A base_url is required to make this request, please provide one and try again."
@@ -400,7 +410,7 @@ class AsyncHttpClient:
400
410
  request_options.get("timeout_in_seconds")
401
411
  if request_options is not None
402
412
  and request_options.get("timeout_in_seconds") is not None
403
- else self.base_timeout
413
+ else self.base_timeout()
404
414
  )
405
415
 
406
416
  json_body, data_body = get_request_body(
@@ -414,7 +424,7 @@ class AsyncHttpClient:
414
424
  headers=jsonable_encoder(
415
425
  remove_none_from_dict(
416
426
  {
417
- **self.base_headers,
427
+ **self.base_headers(),
418
428
  **(headers if headers is not None else {}),
419
429
  **(
420
430
  request_options.get("additional_headers", {}) or {}
@@ -447,9 +457,11 @@ class AsyncHttpClient:
447
457
  json=json_body,
448
458
  data=data_body,
449
459
  content=content,
450
- files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
451
- if files is not None
452
- else None,
460
+ files=(
461
+ convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
462
+ if files is not None
463
+ else None
464
+ ),
453
465
  timeout=timeout,
454
466
  )
455
467
 
@@ -500,7 +512,7 @@ class AsyncHttpClient:
500
512
  request_options.get("timeout_in_seconds")
501
513
  if request_options is not None
502
514
  and request_options.get("timeout_in_seconds") is not None
503
- else self.base_timeout
515
+ else self.base_timeout()
504
516
  )
505
517
 
506
518
  json_body, data_body = get_request_body(
@@ -513,7 +525,7 @@ class AsyncHttpClient:
513
525
  headers=jsonable_encoder(
514
526
  remove_none_from_dict(
515
527
  {
516
- **self.base_headers,
528
+ **self.base_headers(),
517
529
  **(headers if headers is not None else {}),
518
530
  **(
519
531
  request_options.get("additional_headers", {})
@@ -545,9 +557,11 @@ class AsyncHttpClient:
545
557
  json=json_body,
546
558
  data=data_body,
547
559
  content=content,
548
- files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
549
- if files is not None
550
- else None,
560
+ files=(
561
+ convert_file_dict_to_httpx_tuples(remove_none_from_dict(files))
562
+ if files is not None
563
+ else None
564
+ ),
551
565
  timeout=timeout,
552
566
  ) as stream:
553
567
  yield stream
@@ -10,6 +10,7 @@ import typing_extensions
10
10
  import pydantic
11
11
 
12
12
  from .datetime_utils import serialize_datetime
13
+ from .serialization import convert_and_respect_annotation_metadata
13
14
 
14
15
  IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.")
15
16
 
@@ -56,11 +57,14 @@ Model = typing.TypeVar("Model", bound=pydantic.BaseModel)
56
57
 
57
58
 
58
59
  def parse_obj_as(type_: typing.Type[T], object_: typing.Any) -> T:
60
+ dealiased_object = convert_and_respect_annotation_metadata(
61
+ object_=object_, annotation=type_, direction="read"
62
+ )
59
63
  if IS_PYDANTIC_V2:
60
64
  adapter = pydantic.TypeAdapter(type_) # type: ignore # Pydantic v2
61
- return adapter.validate_python(object_)
65
+ return adapter.validate_python(dealiased_object)
62
66
  else:
63
- return pydantic.parse_obj_as(type_, object_)
67
+ return pydantic.parse_obj_as(type_, dealiased_object)
64
68
 
65
69
 
66
70
  def to_jsonable_with_fallback(
@@ -75,9 +79,53 @@ def to_jsonable_with_fallback(
75
79
 
76
80
 
77
81
  class UniversalBaseModel(pydantic.BaseModel):
78
- class Config:
79
- populate_by_name = True
80
- json_encoders = {dt.datetime: serialize_datetime}
82
+ if IS_PYDANTIC_V2:
83
+ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(
84
+ # Allow fields begining with `model_` to be used in the model
85
+ protected_namespaces=(),
86
+ ) # type: ignore # Pydantic v2
87
+
88
+ @pydantic.model_serializer(mode="wrap", when_used="json") # type: ignore # Pydantic v2
89
+ def serialize_model(
90
+ self, handler: pydantic.SerializerFunctionWrapHandler
91
+ ) -> typing.Any: # type: ignore # Pydantic v2
92
+ serialized = handler(self)
93
+ data = {
94
+ k: serialize_datetime(v) if isinstance(v, dt.datetime) else v
95
+ for k, v in serialized.items()
96
+ }
97
+ return data
98
+
99
+ else:
100
+
101
+ class Config:
102
+ smart_union = True
103
+ json_encoders = {dt.datetime: serialize_datetime}
104
+
105
+ @classmethod
106
+ def model_construct(
107
+ cls: typing.Type["Model"],
108
+ _fields_set: typing.Optional[typing.Set[str]] = None,
109
+ **values: typing.Any,
110
+ ) -> "Model":
111
+ dealiased_object = convert_and_respect_annotation_metadata(
112
+ object_=values, annotation=cls, direction="read"
113
+ )
114
+ return cls.construct(_fields_set, **dealiased_object)
115
+
116
+ @classmethod
117
+ def construct(
118
+ cls: typing.Type["Model"],
119
+ _fields_set: typing.Optional[typing.Set[str]] = None,
120
+ **values: typing.Any,
121
+ ) -> "Model":
122
+ dealiased_object = convert_and_respect_annotation_metadata(
123
+ object_=values, annotation=cls, direction="read"
124
+ )
125
+ if IS_PYDANTIC_V2:
126
+ return super().model_construct(_fields_set, **dealiased_object) # type: ignore # Pydantic v2
127
+ else:
128
+ return super().construct(_fields_set, **dealiased_object)
81
129
 
82
130
  def json(self, **kwargs: typing.Any) -> str:
83
131
  kwargs_with_defaults: typing.Any = {
@@ -95,30 +143,97 @@ class UniversalBaseModel(pydantic.BaseModel):
95
143
  Override the default dict method to `exclude_unset` by default. This function patches
96
144
  `exclude_unset` to work include fields within non-None default values.
97
145
  """
98
- _fields_set = self.__fields_set__
99
-
100
- fields = _get_model_fields(self.__class__)
101
- for name, field in fields.items():
102
- if name not in _fields_set:
103
- default = _get_field_default(field)
104
-
105
- # If the default values are non-null act like they've been set
106
- # This effectively allows exclude_unset to work like exclude_none where
107
- # the latter passes through intentionally set none values.
108
- if default != None:
109
- _fields_set.add(name)
110
-
111
- kwargs_with_defaults_exclude_unset: typing.Any = {
112
- "by_alias": True,
113
- "exclude_unset": True,
114
- "include": _fields_set,
115
- **kwargs,
116
- }
117
-
146
+ # Note: the logic here is multi-plexed given the levers exposed in Pydantic V1 vs V2
147
+ # Pydantic V1's .dict can be extremely slow, so we do not want to call it twice.
148
+ #
149
+ # We'd ideally do the same for Pydantic V2, but it shells out to a library to serialize models
150
+ # that we have less control over, and this is less intrusive than custom serializers for now.
118
151
  if IS_PYDANTIC_V2:
119
- return super().model_dump(**kwargs_with_defaults_exclude_unset) # type: ignore # Pydantic v2
152
+ kwargs_with_defaults_exclude_unset: typing.Any = {
153
+ **kwargs,
154
+ "by_alias": True,
155
+ "exclude_unset": True,
156
+ "exclude_none": False,
157
+ }
158
+ kwargs_with_defaults_exclude_none: typing.Any = {
159
+ **kwargs,
160
+ "by_alias": True,
161
+ "exclude_none": True,
162
+ "exclude_unset": False,
163
+ }
164
+ dict_dump = deep_union_pydantic_dicts(
165
+ super().model_dump(**kwargs_with_defaults_exclude_unset), # type: ignore # Pydantic v2
166
+ super().model_dump(**kwargs_with_defaults_exclude_none), # type: ignore # Pydantic v2
167
+ )
168
+
169
+ else:
170
+ _fields_set = self.__fields_set__.copy()
171
+
172
+ fields = _get_model_fields(self.__class__)
173
+ for name, field in fields.items():
174
+ if name not in _fields_set:
175
+ default = _get_field_default(field)
176
+
177
+ # If the default values are non-null act like they've been set
178
+ # This effectively allows exclude_unset to work like exclude_none where
179
+ # the latter passes through intentionally set none values.
180
+ if default is not None or (
181
+ "exclude_unset" in kwargs and not kwargs["exclude_unset"]
182
+ ):
183
+ _fields_set.add(name)
184
+
185
+ if default is not None:
186
+ self.__fields_set__.add(name)
187
+
188
+ kwargs_with_defaults_exclude_unset_include_fields: typing.Any = {
189
+ "by_alias": True,
190
+ "exclude_unset": True,
191
+ "include": _fields_set,
192
+ **kwargs,
193
+ }
194
+
195
+ dict_dump = super().dict(
196
+ **kwargs_with_defaults_exclude_unset_include_fields
197
+ )
198
+
199
+ return convert_and_respect_annotation_metadata(
200
+ object_=dict_dump, annotation=self.__class__, direction="write"
201
+ )
202
+
203
+
204
+ def _union_list_of_pydantic_dicts(
205
+ source: typing.List[typing.Any], destination: typing.List[typing.Any]
206
+ ) -> typing.List[typing.Any]:
207
+ converted_list: typing.List[typing.Any] = []
208
+ for i, item in enumerate(source):
209
+ destination_value = destination[i] # type: ignore
210
+ if isinstance(item, dict):
211
+ converted_list.append(deep_union_pydantic_dicts(item, destination_value))
212
+ elif isinstance(item, list):
213
+ converted_list.append(
214
+ _union_list_of_pydantic_dicts(item, destination_value)
215
+ )
120
216
  else:
121
- return super().dict(**kwargs_with_defaults_exclude_unset)
217
+ converted_list.append(item)
218
+ return converted_list
219
+
220
+
221
+ def deep_union_pydantic_dicts(
222
+ source: typing.Dict[str, typing.Any], destination: typing.Dict[str, typing.Any]
223
+ ) -> typing.Dict[str, typing.Any]:
224
+ for key, value in source.items():
225
+ node = destination.setdefault(key, {})
226
+ if isinstance(value, dict):
227
+ deep_union_pydantic_dicts(value, node)
228
+ # Note: we do not do this same processing for sets given we do not have sets of models
229
+ # and given the sets are unordered, the processing of the set and matching objects would
230
+ # be non-trivial.
231
+ elif isinstance(value, list):
232
+ destination[key] = _union_list_of_pydantic_dicts(value, node)
233
+ else:
234
+ destination[key] = value
235
+
236
+ return destination
122
237
 
123
238
 
124
239
  if IS_PYDANTIC_V2:
@@ -145,11 +260,11 @@ def encode_by_type(o: typing.Any) -> typing.Any:
145
260
  return encoder(o)
146
261
 
147
262
 
148
- def update_forward_refs(model: typing.Type["Model"]) -> None:
263
+ def update_forward_refs(model: typing.Type["Model"], **localns: typing.Any) -> None:
149
264
  if IS_PYDANTIC_V2:
150
265
  model.model_rebuild(raise_errors=False) # type: ignore # Pydantic v2
151
266
  else:
152
- model.update_forward_refs()
267
+ model.update_forward_refs(**localns)
153
268
 
154
269
 
155
270
  # Mirrors Pydantic's internal typing
@@ -23,6 +23,8 @@ class RequestOptions(typing.TypedDict, total=False):
23
23
  - additional_query_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's query parameters dict
24
24
 
25
25
  - additional_body_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's body parameters dict
26
+
27
+ - chunk_size: int. The size, in bytes, to process each chunk of data being streamed back within the response. This equates to leveraging `chunk_size` within `requests` or `httpx`, and is only leveraged for file downloads.
26
28
  """
27
29
 
28
30
  timeout_in_seconds: NotRequired[int]
@@ -30,3 +32,4 @@ class RequestOptions(typing.TypedDict, total=False):
30
32
  additional_headers: NotRequired[typing.Dict[str, typing.Any]]
31
33
  additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]]
32
34
  additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]]
35
+ chunk_size: NotRequired[int]
@@ -1,10 +1,13 @@
1
1
  # This file was auto-generated by Fern from our API Definition.
2
2
 
3
3
  import collections
4
+ import inspect
4
5
  import typing
5
6
 
6
7
  import typing_extensions
7
8
 
9
+ import pydantic
10
+
8
11
 
9
12
  class FieldMetadata:
10
13
  """
@@ -29,6 +32,7 @@ def convert_and_respect_annotation_metadata(
29
32
  object_: typing.Any,
30
33
  annotation: typing.Any,
31
34
  inner_type: typing.Optional[typing.Any] = None,
35
+ direction: typing.Literal["read", "write"],
32
36
  ) -> typing.Any:
33
37
  """
34
38
  Respect the metadata annotations on a field, such as aliasing. This function effectively
@@ -56,49 +60,79 @@ def convert_and_respect_annotation_metadata(
56
60
  inner_type = annotation
57
61
 
58
62
  clean_type = _remove_annotations(inner_type)
63
+ # Pydantic models
64
+ if (
65
+ inspect.isclass(clean_type)
66
+ and issubclass(clean_type, pydantic.BaseModel)
67
+ and isinstance(object_, typing.Mapping)
68
+ ):
69
+ return _convert_mapping(object_, clean_type, direction)
70
+ # TypedDicts
59
71
  if typing_extensions.is_typeddict(clean_type) and isinstance(
60
72
  object_, typing.Mapping
61
73
  ):
62
- return _convert_typeddict(object_, clean_type)
74
+ return _convert_mapping(object_, clean_type, direction)
63
75
 
64
76
  if (
65
- # If you're iterating on a string, do not bother to coerce it to a sequence.
66
- (not isinstance(object_, str))
67
- and (
68
- (
69
- (
70
- typing_extensions.get_origin(clean_type) == typing.List
71
- or typing_extensions.get_origin(clean_type) == list
72
- or clean_type == typing.List
73
- )
74
- and isinstance(object_, typing.List)
75
- )
76
- or (
77
- (
78
- typing_extensions.get_origin(clean_type) == typing.Set
79
- or typing_extensions.get_origin(clean_type) == set
80
- or clean_type == typing.Set
81
- )
82
- and isinstance(object_, typing.Set)
77
+ typing_extensions.get_origin(clean_type) == typing.Dict
78
+ or typing_extensions.get_origin(clean_type) == dict
79
+ or clean_type == typing.Dict
80
+ ) and isinstance(object_, typing.Dict):
81
+ key_type = typing_extensions.get_args(clean_type)[0]
82
+ value_type = typing_extensions.get_args(clean_type)[1]
83
+
84
+ return {
85
+ key: convert_and_respect_annotation_metadata(
86
+ object_=value,
87
+ annotation=annotation,
88
+ inner_type=value_type,
89
+ direction=direction,
83
90
  )
84
- or (
85
- (
86
- typing_extensions.get_origin(clean_type) == typing.Sequence
87
- or typing_extensions.get_origin(clean_type)
88
- == collections.abc.Sequence
89
- or clean_type == typing.Sequence
91
+ for key, value in object_.items()
92
+ }
93
+
94
+ # If you're iterating on a string, do not bother to coerce it to a sequence.
95
+ if not isinstance(object_, str):
96
+ if (
97
+ typing_extensions.get_origin(clean_type) == typing.Set
98
+ or typing_extensions.get_origin(clean_type) == set
99
+ or clean_type == typing.Set
100
+ ) and isinstance(object_, typing.Set):
101
+ inner_type = typing_extensions.get_args(clean_type)[0]
102
+ return {
103
+ convert_and_respect_annotation_metadata(
104
+ object_=item,
105
+ annotation=annotation,
106
+ inner_type=inner_type,
107
+ direction=direction,
90
108
  )
91
- and isinstance(object_, typing.Sequence)
109
+ for item in object_
110
+ }
111
+ elif (
112
+ (
113
+ typing_extensions.get_origin(clean_type) == typing.List
114
+ or typing_extensions.get_origin(clean_type) == list
115
+ or clean_type == typing.List
92
116
  )
93
- )
94
- ):
95
- inner_type = typing_extensions.get_args(clean_type)[0]
96
- return [
97
- convert_and_respect_annotation_metadata(
98
- object_=item, annotation=annotation, inner_type=inner_type
117
+ and isinstance(object_, typing.List)
118
+ ) or (
119
+ (
120
+ typing_extensions.get_origin(clean_type) == typing.Sequence
121
+ or typing_extensions.get_origin(clean_type) == collections.abc.Sequence
122
+ or clean_type == typing.Sequence
99
123
  )
100
- for item in object_
101
- ]
124
+ and isinstance(object_, typing.Sequence)
125
+ ):
126
+ inner_type = typing_extensions.get_args(clean_type)[0]
127
+ return [
128
+ convert_and_respect_annotation_metadata(
129
+ object_=item,
130
+ annotation=annotation,
131
+ inner_type=inner_type,
132
+ direction=direction,
133
+ )
134
+ for item in object_
135
+ ]
102
136
 
103
137
  if typing_extensions.get_origin(clean_type) == typing.Union:
104
138
  # We should be able to ~relatively~ safely try to convert keys against all
@@ -107,7 +141,10 @@ def convert_and_respect_annotation_metadata(
107
141
  # Or if another member aliases a field of the same name that another member does not.
108
142
  for member in typing_extensions.get_args(clean_type):
109
143
  object_ = convert_and_respect_annotation_metadata(
110
- object_=object_, annotation=annotation, inner_type=member
144
+ object_=object_,
145
+ annotation=annotation,
146
+ inner_type=member,
147
+ direction=direction,
111
148
  )
112
149
  return object_
113
150
 
@@ -120,19 +157,37 @@ def convert_and_respect_annotation_metadata(
120
157
  return object_
121
158
 
122
159
 
123
- def _convert_typeddict(
124
- object_: typing.Mapping[str, object], expected_type: typing.Any
160
+ def _convert_mapping(
161
+ object_: typing.Mapping[str, object],
162
+ expected_type: typing.Any,
163
+ direction: typing.Literal["read", "write"],
125
164
  ) -> typing.Mapping[str, object]:
126
165
  converted_object: typing.Dict[str, object] = {}
127
166
  annotations = typing_extensions.get_type_hints(expected_type, include_extras=True)
167
+ aliases_to_field_names = _get_alias_to_field_name(annotations)
128
168
  for key, value in object_.items():
129
- type_ = annotations.get(key)
169
+ if direction == "read" and key in aliases_to_field_names:
170
+ dealiased_key = aliases_to_field_names.get(key)
171
+ if dealiased_key is not None:
172
+ type_ = annotations.get(dealiased_key)
173
+ else:
174
+ type_ = annotations.get(key)
175
+ # Note you can't get the annotation by the field name if you're in read mode, so you must check the aliases map
176
+ #
177
+ # So this is effectively saying if we're in write mode, and we don't have a type, or if we're in read mode and we don't have an alias
178
+ # then we can just pass the value through as is
130
179
  if type_ is None:
131
180
  converted_object[key] = value
181
+ elif direction == "read" and key not in aliases_to_field_names:
182
+ converted_object[key] = convert_and_respect_annotation_metadata(
183
+ object_=value, annotation=type_, direction=direction
184
+ )
132
185
  else:
133
186
  converted_object[
134
- _alias_key(key, type_)
135
- ] = convert_and_respect_annotation_metadata(object_=value, annotation=type_)
187
+ _alias_key(key, type_, direction, aliases_to_field_names)
188
+ ] = convert_and_respect_annotation_metadata(
189
+ object_=value, annotation=type_, direction=direction
190
+ )
136
191
  return converted_object
137
192
 
138
193
 
@@ -165,7 +220,39 @@ def _remove_annotations(type_: typing.Any) -> typing.Any:
165
220
  return type_
166
221
 
167
222
 
168
- def _alias_key(key: str, type_: typing.Any) -> str:
223
+ def get_alias_to_field_mapping(type_: typing.Any) -> typing.Dict[str, str]:
224
+ annotations = typing_extensions.get_type_hints(type_, include_extras=True)
225
+ return _get_alias_to_field_name(annotations)
226
+
227
+
228
+ def get_field_to_alias_mapping(type_: typing.Any) -> typing.Dict[str, str]:
229
+ annotations = typing_extensions.get_type_hints(type_, include_extras=True)
230
+ return _get_field_to_alias_name(annotations)
231
+
232
+
233
+ def _get_alias_to_field_name(
234
+ field_to_hint: typing.Dict[str, typing.Any],
235
+ ) -> typing.Dict[str, str]:
236
+ aliases = {}
237
+ for field, hint in field_to_hint.items():
238
+ maybe_alias = _get_alias_from_type(hint)
239
+ if maybe_alias is not None:
240
+ aliases[maybe_alias] = field
241
+ return aliases
242
+
243
+
244
+ def _get_field_to_alias_name(
245
+ field_to_hint: typing.Dict[str, typing.Any],
246
+ ) -> typing.Dict[str, str]:
247
+ aliases = {}
248
+ for field, hint in field_to_hint.items():
249
+ maybe_alias = _get_alias_from_type(hint)
250
+ if maybe_alias is not None:
251
+ aliases[field] = maybe_alias
252
+ return aliases
253
+
254
+
255
+ def _get_alias_from_type(type_: typing.Any) -> typing.Optional[str]:
169
256
  maybe_annotated_type = _get_annotation(type_)
170
257
 
171
258
  if maybe_annotated_type is not None:
@@ -175,5 +262,15 @@ def _alias_key(key: str, type_: typing.Any) -> str:
175
262
  for annotation in annotations:
176
263
  if isinstance(annotation, FieldMetadata) and annotation.alias is not None:
177
264
  return annotation.alias
265
+ return None
266
+
178
267
 
179
- return key
268
+ def _alias_key(
269
+ key: str,
270
+ type_: typing.Any,
271
+ direction: typing.Literal["read", "write"],
272
+ aliases_to_field_names: typing.Dict[str, str],
273
+ ) -> str:
274
+ if direction == "read":
275
+ return aliases_to_field_names.get(key, key)
276
+ return _get_alias_from_type(type_=type_) or key