Skip to content

Commit dba6c11

Browse files
authored
[core] add as_attribute_dict to azure.core.serialization (#41517)
1 parent 60e06f2 commit dba6c11

28 files changed

+1454
-298
lines changed

eng/pipelines/tsp-spec-sync.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ extends:
2929
displayName: 'Configure Git'
3030
3131
- script: |
32-
tsp-client update --local-spec-repo ../../specs/scratch/
32+
tsp-client update --local-spec-repo ../../specs/modeltypes
3333
displayName: 'Run TSP Client Update'
34-
workingDirectory: '$(Build.SourcesDirectory)/sdk/core/azure-core/tests/specs_sdk/scratch'
34+
workingDirectory: '$(Build.SourcesDirectory)/sdk/core/azure-core/tests/specs_sdk/modeltypes'
3535
3636
- script: |
3737
changes=$(git status --porcelain)

sdk/core/azure-core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Added a `start_time` keyword argument to the `start_span` and `start_as_current_span` methods in the `OpenTelemetryTracer` class. This allows users to specify a custom start time for created spans. #41106
88
- Added a `context` keyword argument to the `start_span` and `start_as_current_span` methods in the `OpenTelemetryTracer` class. This allows users to specify a custom parent context for created spans. #41511
9+
- Added method `as_attribute_dict` to `azure.core.serialization` for backcompat migration purposes. Will return a generated model as a dictionary where the keys are in attribute syntax.
910
- Added `is_generated_model` method to `azure.core.serialization`. Returns whether a given input is a model from one of our generated sdks. #41445
1011

1112
### Breaking Changes

sdk/core/azure-core/azure/core/serialization.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# --------------------------------------------------------------------------
77
import base64
88
from json import JSONEncoder
9-
from typing import Union, cast, Any
9+
from typing import Dict, Optional, Union, cast, Any
1010
from datetime import datetime, date, time, timedelta
1111
from datetime import timezone
1212

@@ -140,3 +140,111 @@ def is_generated_model(obj: Any) -> bool:
140140
:rtype: bool
141141
"""
142142
return bool(getattr(obj, "_is_model", False) or hasattr(obj, "_attribute_map"))
143+
144+
145+
def _is_readonly(p: Any) -> bool:
146+
"""Check if an attribute is readonly.
147+
148+
:param any p: The property to check.
149+
:return: True if the property is readonly, False otherwise.
150+
:rtype: bool
151+
"""
152+
try:
153+
return p._visibility == ["read"] # pylint: disable=protected-access
154+
except AttributeError:
155+
return False
156+
157+
158+
def _as_attribute_dict_value(v: Any, *, exclude_readonly: bool = False) -> Any:
159+
if v is None or isinstance(v, _Null):
160+
return None
161+
if isinstance(v, (list, tuple, set)):
162+
return type(v)(_as_attribute_dict_value(x, exclude_readonly=exclude_readonly) for x in v)
163+
if isinstance(v, dict):
164+
return {dk: _as_attribute_dict_value(dv, exclude_readonly=exclude_readonly) for dk, dv in v.items()}
165+
return as_attribute_dict(v, exclude_readonly=exclude_readonly) if is_generated_model(v) else v
166+
167+
168+
def _get_flattened_attribute(obj: Any) -> Optional[str]:
169+
flattened_items = None
170+
try:
171+
flattened_items = getattr(obj, next(a for a in dir(obj) if "__flattened_items" in a), None)
172+
except StopIteration:
173+
return None
174+
175+
if flattened_items is None:
176+
return None
177+
178+
for k, v in obj._attr_to_rest_field.items(): # pylint: disable=protected-access
179+
try:
180+
if set(v._class_type._attr_to_rest_field.keys()).intersection( # pylint: disable=protected-access
181+
set(flattened_items)
182+
):
183+
return k
184+
except AttributeError:
185+
# if the attribute does not have _class_type, it is not a typespec generated model
186+
continue
187+
return None
188+
189+
190+
def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, Any]:
191+
"""Convert an object to a dictionary of its attributes.
192+
193+
Made solely for backcompatibility with the legacy `.as_dict()` on msrest models.
194+
195+
.. deprecated::1.35.0
196+
This function is added for backcompat purposes only.
197+
198+
:param any obj: The object to convert to a dictionary
199+
:keyword bool exclude_readonly: Whether to exclude readonly properties
200+
:return: A dictionary containing the object's attributes
201+
:rtype: dict[str, any]
202+
:raises TypeError: If the object is not a generated model instance
203+
"""
204+
if not is_generated_model(obj):
205+
raise TypeError("Object must be a generated model instance.")
206+
if hasattr(obj, "_attribute_map"):
207+
# msrest generated model
208+
return obj.as_dict(keep_readonly=not exclude_readonly)
209+
try:
210+
# now we're a typespec generated model
211+
result = {}
212+
readonly_props = set()
213+
214+
# create a reverse mapping from rest field name to attribute name
215+
rest_to_attr = {}
216+
flattened_attribute = _get_flattened_attribute(obj)
217+
for attr_name, rest_field in obj._attr_to_rest_field.items(): # pylint: disable=protected-access
218+
219+
if exclude_readonly and _is_readonly(rest_field):
220+
# if we're excluding readonly properties, we need to track them
221+
readonly_props.add(rest_field._rest_name) # pylint: disable=protected-access
222+
if flattened_attribute == attr_name:
223+
for fk, fv in rest_field._class_type._attr_to_rest_field.items(): # pylint: disable=protected-access
224+
rest_to_attr[fv._rest_name] = fk # pylint: disable=protected-access
225+
else:
226+
rest_to_attr[rest_field._rest_name] = attr_name # pylint: disable=protected-access
227+
for k, v in obj.items():
228+
if exclude_readonly and k in readonly_props: # pyright: ignore
229+
continue
230+
if k == flattened_attribute:
231+
for fk, fv in v.items():
232+
result[rest_to_attr.get(fk, fk)] = _as_attribute_dict_value(fv, exclude_readonly=exclude_readonly)
233+
else:
234+
is_multipart_file_input = False
235+
try:
236+
is_multipart_file_input = next( # pylint: disable=protected-access
237+
rf
238+
for rf in obj._attr_to_rest_field.values() # pylint: disable=protected-access
239+
if rf._rest_name == k # pylint: disable=protected-access
240+
)._is_multipart_file_input
241+
except StopIteration:
242+
pass
243+
244+
result[rest_to_attr.get(k, k)] = (
245+
v if is_multipart_file_input else _as_attribute_dict_value(v, exclude_readonly=exclude_readonly)
246+
)
247+
return result
248+
except AttributeError as exc:
249+
# not a typespec generated model
250+
raise TypeError("Object must be a generated model instance.") from exc

sdk/core/azure-core/dev_requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ aiohttp<3.8.6 ; platform_python_implementation == "PyPy"
55
aiohttp ; platform_python_implementation != "PyPy"
66
-e ../../../tools/azure-sdk-tools
77
-e tests/testserver_tests/coretestserver
8-
-e tests/specs_sdk/scratch
8+
-e tests/specs_sdk/modeltypes
99
pytest-trio
1010
azure-storage-blob
1111
azure-data-tables
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import "@azure-tools/typespec-client-generator-core";
2+
3+
using Azure.ClientGenerator.Core;
4+
5+
@usage(Usage.input)
6+
namespace ModelTypes;
7+
/** A scratch model for testing purposes */
8+
9+
model Scratch {
10+
/** A string property */
11+
prop: string;
12+
}
13+
14+
/**
15+
* Model with one level of flattening
16+
*/
17+
model FlattenModel {
18+
name: string;
19+
20+
#suppress "deprecated" "@flattenProperty decorator is not recommended to use."
21+
@flattenProperty
22+
properties: PropertiesModel;
23+
}
24+
25+
/**
26+
* Properties model
27+
*/
28+
model PropertiesModel {
29+
@encodedName("application/json", "modelDescription")
30+
description: string;
31+
age: int32;
32+
}
33+
34+
/**
35+
* Model with a property that has a client name
36+
*/
37+
model ClientNameAndJsonEncodedNameModel {
38+
@doc("Pass in true")
39+
@encodedName("application/json", "wireName")
40+
clientName: string;
41+
}
42+
43+
/**
44+
* Model with a readonly property
45+
*/
46+
model ReadonlyModel {
47+
@visibility(Lifecycle.Read)
48+
id: int32;
49+
}
50+
51+
@doc("This is base model for polymorphic multiple levels inheritance with a discriminator.")
52+
@discriminator("kind")
53+
model Fish {
54+
age: int32;
55+
}
56+
57+
@doc("The second level model in polymorphic multiple levels inheritance and it defines a new discriminator.")
58+
@discriminator("sharkType")
59+
model Shark extends Fish {
60+
kind: "shark";
61+
sharkType: string;
62+
}
63+
64+
@doc("The second level model in polymorphic multiple levels inheritance which contains references to other polymorphic instances.")
65+
model Salmon extends Fish {
66+
kind: "salmon";
67+
friends?: Fish[];
68+
hate?: Record<Fish>;
69+
lifePartner?: Fish;
70+
}
71+
72+
@doc("The third level model SawShark in polymorphic multiple levels inheritance.")
73+
model SawShark extends Shark {
74+
sharkType: "saw";
75+
}
76+
77+
@doc("The third level model GoblinShark in polymorphic multiple levels inheritance.")
78+
model GoblinShark extends Shark {
79+
sharkType: "goblin";
80+
}
81+
82+
model RecursiveElement extends Element {
83+
level: int8;
84+
}
85+
86+
model Element {
87+
recursiveElement?: RecursiveElement[];
88+
}
File renamed without changes.

sdk/core/azure-core/tests/specs/scratch/main.tsp

Lines changed: 0 additions & 47 deletions
This file was deleted.
File renamed without changes.
File renamed without changes.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
include *.md
22
include LICENSE
3-
include modeltest/py.typed
3+
include modeltypes/py.typed
44
recursive-include tests *.py
55
recursive-include samples *.py *.md

0 commit comments

Comments
 (0)