Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ Release History

4.6.2
++++++
* Fix invalid Python identifiers generated from generic type names containing angle brackets (e.g. ``Record<...>``).
* Fix ``SyntaxError`` in generated subresource create/update commands caused by an unflattened array/dict element body argument.
* Fix loss of command argument customizations when regenerating a resource migrated from Swagger to TypeSpec, where the request body root changes from ``$parameters`` to ``$resource``.
* Emit ``location`` as a ResourceLocation and hide the resource-envelope ``id`` (read-only ResourceId) for legacy ``@customAzureResource`` models, matching the Swagger-generated commands.
* Align generated ``@cls`` reference class names with the Swagger convertor (preserve PascalCase and drop the create/update visibility infix) so TypeSpec output matches Swagger.
* Harden the browser TypeSpec host with retry and stat caching for reliable resource picking.
* Remove the ``setuptools<81`` upper bound; require ``setuptools>=78.1.1`` instead (includes the CVE-2025-47273 fix). aaz-dev only renders ``setup.py`` templates and never executes them, so newer setuptools does not affect it; in a shared dev environment the effective version is still bounded by ``azdev``'s own ``setuptools`` requirement.

4.6.1
Expand Down
7 changes: 5 additions & 2 deletions src/aaz_dev/command/controller/workspace_cfg_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1043,10 +1043,13 @@ def _inherit_modification_in_command(cls, command, ref_command):

# inherit arguments modification
ref_args = []
ref_options = {}
if ref_command.arg_groups:
for group in ref_command.arg_groups:
ref_args.extend(group.args)
command.generate_args(ref_args=ref_args)
for arg in group.args:
ref_args.append(arg)
ref_options[arg.var] = [*arg.options]
command.generate_args(ref_args=ref_args, ref_options=ref_options)

# inherit outputs
command.generate_outputs(ref_outputs=ref_command.outputs)
Expand Down
12 changes: 8 additions & 4 deletions src/aaz_dev/command/model/configuration/_arg_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ def _need_flatten(self):
if self.get_cls():
# not support to flatten object which is a cls.
return False
if self._parent is None and self.schema.props and (
self._arg_var.endswith("[]") or self._arg_var.endswith("{}")):
# Always flatten forked array/dict elements used as a command root body.
# Otherwise, stale inherited configs may regenerate invalid options such as "...[]" or "...{}".
return True
if self._flatten is not None:
return self._flatten
if self.schema.client_flatten:
Expand Down Expand Up @@ -334,10 +339,9 @@ def get_hide(self):
if getattr(self.schema, 'name', None) == 'id' and not self.get_required() and self._parent and \
isinstance(self.schema, CMDResourceIdSchema):
if self._arg_var.split('.', maxsplit=1)[-1] == 'id':
# hide top level 'id' property when it has 'name' property,
for prop in self._parent.schema.props:
if prop.name == 'name':
return True
# hide the resource's own read-only ARM id. Swagger relied on a sibling frozen
# 'name'; TypeSpec strips read-only siblings, so hide any optional top-level id.
return True

if getattr(self.schema, 'name', None) in ['userAssignedIdentities', 'type'] and self._parent and \
isinstance(self._parent.schema, CMDIdentityObjectSchema):
Expand Down
101 changes: 101 additions & 0 deletions src/aaz_dev/command/model/configuration/_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ def generate_args(self, ref_args=None, ref_options=None):
ref_args.extend(group.args)
ref_args = ref_args or None

if ref_args:
# Reference args may use a different body root (e.g. "$parameters.*" vs tsp's "$resource.*"),
# which breaks the arg builder's exact-var match and loses inherited customizations. Remap
# them onto the current body root so they match and are inherited.
ref_args = self._remap_ref_args_to_body_root(ref_args)

arguments = {}
has_subresource = False
if self.subresource_selector:
Expand All @@ -81,10 +87,105 @@ def generate_args(self, ref_args=None, ref_options=None):
arg.options = [*ref_options[arg.var]]
arguments[arg.var] = arg

if ref_options:
# Different generators use different body arg roots (e.g. "$parameters.*" vs "$resource.*").
# When inheriting cfg changes from another generator, remap body-arg customizations to the
# current root so existing option overrides can still be matched and applied.
self._apply_ref_options_with_body_root_remap(arguments, ref_options)

arguments = handle_duplicated_options(
arguments, has_subresource=has_subresource, operation_id=self.operations[-1].operation_id)
self.arg_groups = self._build_arg_groups(arguments)

_NON_BODY_ARG_ROOTS = ("$Path", "$Query", "$Header")

@classmethod
def _apply_ref_options_with_body_root_remap(cls, arguments, ref_options):
def root_of(var):
return var.split('.', 1)[0]

body_roots = {root_of(var) for var in arguments if root_of(var) not in cls._NON_BODY_ARG_ROOTS}
if len(body_roots) != 1:
# only remap when there is exactly one body root; ambiguous otherwise.
return
cur_root = body_roots.pop()
for key, options in ref_options.items():
root = root_of(key)
if root in cls._NON_BODY_ARG_ROOTS or root == cur_root:
continue
remapped = cur_root + key[len(root):]
if remapped in arguments:
arguments[remapped].options = [*options]

def _detect_body_arg_root(self):
roots = set()
for op in self.operations:
schema = None
http = getattr(op, 'http', None)
if http is not None:
request = getattr(http, 'request', None)
body = getattr(request, 'body', None) if request is not None else None
json_body = getattr(body, 'json', None) if body is not None else None
schema = getattr(json_body, 'schema', None) if json_body is not None else None
else:
for action_attr in ('instance_update', 'instance_create'):
action = getattr(op, action_attr, None)
json_body = getattr(action, 'json', None) if action is not None else None
if json_body is not None:
schema = getattr(json_body, 'schema', None)
break
name = getattr(schema, 'name', None) if schema is not None else None
if name:
# schema.name may be a full path (e.g. "resource.properties.sslCertificates[]");
# keep only the leading root token.
root = '$' + name.replace('$', '')
root = root.split('.', 1)[0].split('[', 1)[0]
roots.add(root)
if len(roots) == 1:
return roots.pop()
return None

def _remap_ref_args_to_body_root(self, ref_args):
def root_of(var):
return var.split('.', 1)[0].split('[', 1)[0]

cur_root = self._detect_body_arg_root()
if not cur_root:
return ref_args
ref_roots = {
root_of(arg.var) for arg in ref_args
if getattr(arg, 'var', None) and root_of(arg.var) not in self._NON_BODY_ARG_ROOTS
}
if len(ref_roots) != 1:
# only remap when the reference args have exactly one body root.
return ref_args
ref_root = ref_roots.pop()
if ref_root == cur_root:
return ref_args
# schematics models can't be deepcopied; round-trip through primitives to clone (preserving
# polymorphic subtypes) so we don't mutate the caller's ref_args.
remapped = list(CMDArgGroup({"name": "", "args": [a.to_primitive() for a in ref_args]}).args)
for arg in remapped:
self._remap_arg_var_root(arg, ref_root, cur_root)
return remapped

@classmethod
def _remap_arg_var_root(cls, node, old_root, new_root):
if node is None:
return
var = getattr(node, 'var', None)
if var:
if var == old_root:
node.var = new_root
elif var.startswith(old_root + '.') or var.startswith(old_root + '['):
node.var = new_root + var[len(old_root):]
for sub in (getattr(node, 'args', None) or []):
cls._remap_arg_var_root(sub, old_root, new_root)
cls._remap_arg_var_root(getattr(node, 'item', None), old_root, new_root)
additional_props = getattr(node, 'additional_props', None)
if additional_props is not None:
cls._remap_arg_var_root(getattr(additional_props, 'item', None), old_root, new_root)

def generate_outputs(self, ref_outputs=None, pageable=None):
if not ref_outputs:
if self.outputs:
Expand Down
122 changes: 122 additions & 0 deletions src/aaz_dev/command/tests/configuration_tests/test_body_root_remap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from command.model.configuration._command import CMDCommand


class _FakeArg:
def __init__(self, options):
self.options = options


def test_body_root_remap_applies_across_generator_roots():
# TypeSpec generates body args under "$resource.*"; the inherited customizations come from a
# Swagger cfg keyed under "$parameters.*". The remap must re-apply them; Path args are untouched.
arguments = {
"$Path.applicationGatewayName": _FakeArg(["gateway-name"]),
"$resource.properties.sslCertificates[].name": _FakeArg(["ssl-certificate-name"]),
"$resource.properties.sslCertificates[].properties.password": _FakeArg(["password"]),
"$resource.properties.sslCertificates[].id": _FakeArg(["id"]),
}
ref_options = {
"$Path.applicationGatewayName": ["gateway-name"],
"$parameters.properties.sslCertificates[].name": ["n", "name"],
"$parameters.properties.sslCertificates[].properties.password": ["cert-password"],
"$parameters.properties.sslCertificates[].id": ["cert-id"],
}
CMDCommand._apply_ref_options_with_body_root_remap(arguments, ref_options)
assert arguments["$resource.properties.sslCertificates[].name"].options == ["n", "name"]
assert arguments["$resource.properties.sslCertificates[].properties.password"].options == ["cert-password"]
assert arguments["$resource.properties.sslCertificates[].id"].options == ["cert-id"]
# Path arg root is stable, so it is left as-is (already inherited via exact match earlier).
assert arguments["$Path.applicationGatewayName"].options == ["gateway-name"]


def test_body_root_remap_noop_when_root_matches():
arguments = {"$resource.foo": _FakeArg(["foo"])}
CMDCommand._apply_ref_options_with_body_root_remap(arguments, {"$resource.foo": ["bar"]})
# exact-var match is handled inline in generate_args, not here; same-root keys are skipped.
assert arguments["$resource.foo"].options == ["foo"]


def test_body_root_remap_skips_ambiguous_multiple_body_roots():
arguments = {"$a.x": _FakeArg(["x"]), "$b.y": _FakeArg(["y"])}
CMDCommand._apply_ref_options_with_body_root_remap(arguments, {"$parameters.x": ["X"]})
assert arguments["$a.x"].options == ["x"]
assert arguments["$b.y"].options == ["y"]


def _ssl_ref_args():
from command.model.configuration._arg import CMDStringArg, CMDObjectArgBase, CMDArrayArg
inner_id = CMDStringArg({"var": "$parameters.properties.sslCertificates[].id",
"options": ["cert-id"], "hide": True})
inner_pwd = CMDStringArg({"var": "$parameters.properties.sslCertificates[].properties.password",
"options": ["cert-password"]})
elem = CMDObjectArgBase({"args": [inner_id, inner_pwd]})
arr = CMDArrayArg({"var": "$parameters.properties.sslCertificates",
"options": ["ssl-certs"], "item": elem})
top = CMDStringArg({"var": "$parameters.location", "options": ["l", "location"]})
return [arr, top]


def _collect_vars(node, out):
var = getattr(node, "var", None)
if var:
out[var] = node
for sub in (getattr(node, "args", None) or []):
_collect_vars(sub, out)
item = getattr(node, "item", None)
if item is not None:
_collect_vars(item, out)


def test_remap_arg_var_root_rewrites_nested_vars_and_preserves_customizations():
ref_args = _ssl_ref_args()
for arg in ref_args:
CMDCommand._remap_arg_var_root(arg, "$parameters", "$resource")
collected = {}
for arg in ref_args:
_collect_vars(arg, collected)
assert set(collected) == {
"$resource.properties.sslCertificates",
"$resource.properties.sslCertificates[].id",
"$resource.properties.sslCertificates[].properties.password",
"$resource.location",
}
# hide/options survive the remap so the current generator can inherit them.
assert collected["$resource.properties.sslCertificates[].id"].hide is True
assert collected["$resource.properties.sslCertificates[].properties.password"].options == ["cert-password"]


class _BodyRootCommand(CMDCommand):
def __init__(self, body_root):
self._body_root = body_root

def _detect_body_arg_root(self):
return self._body_root


def test_remap_ref_args_clones_and_rewrites_body_root():
ref_args = _ssl_ref_args()
cmd = _BodyRootCommand("$resource")
remapped = cmd._remap_ref_args_to_body_root(ref_args)
# original reference args are not mutated (clone) ...
orig = {}
for arg in ref_args:
_collect_vars(arg, orig)
assert all(v.startswith("$parameters") for v in orig)
# ... and the returned args are rewritten onto the current body root.
remapped_vars = {}
for arg in remapped:
_collect_vars(arg, remapped_vars)
assert "$resource.properties.sslCertificates[].id" in remapped_vars
assert remapped_vars["$resource.properties.sslCertificates[].id"].hide is True


def test_remap_ref_args_noop_when_root_matches():
ref_args = _ssl_ref_args()
cmd = _BodyRootCommand("$parameters")
assert cmd._remap_ref_args_to_body_root(ref_args) is ref_args


def test_remap_ref_args_noop_when_no_body_root():
ref_args = _ssl_ref_args()
cmd = _BodyRootCommand(None)
assert cmd._remap_ref_args_to_body_root(ref_args) is ref_args
6 changes: 5 additions & 1 deletion src/aaz_dev/swagger/model/schema/cmd_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,11 @@ def build_schema(self, schema):

def _get_cls_definition_name(self, schema):
assert isinstance(schema, ReferenceSchema)
schema_cls_name = f"{to_camel_case(schema.ref.split('/')[-1].replace('.', ' '))}_{self.mutability}"
# Generic type names carry angle brackets (e.g. "Record<UserAssignedIdentityResourceId>").
# They must be stripped or the derived cls name produces invalid Python identifiers
# like "_args_record<...>" (SyntaxError). See aaz-dev-tools#562.
ref_name = re.sub(r'[<>]', '', schema.ref.split('/')[-1])
schema_cls_name = f"{to_camel_case(ref_name.replace('.', ' '))}_{self.mutability}"
if self.mutability != MutabilityEnum.Read:
if self.read_only:
schema_cls_name += "_read"
Expand Down
3 changes: 3 additions & 0 deletions src/aaz_dev/utils/case.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ def to_camel_case(name, delimeters=""):

def to_snake_case(name, separator='_'):
assert isinstance(name, str)
# defense-in-depth: drop angle brackets from generic type names (e.g. "Record<Foo>") so the
# result is a valid Python identifier. See aaz-dev-tools#562.
name = name.replace('<', '').replace('>', '')
name = re.sub('(.)([A-Z][a-z]+)', r'\1' + separator + r'\2', name)
name = re.sub('([a-z0-9])([A-Z])', r'\1' + separator + r'\2', name).lower()
return name.replace('-', separator).replace('_', separator)
Expand Down
Empty file.
18 changes: 18 additions & 0 deletions src/aaz_dev/utils/tests/test_case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import ast

from utils.case import to_snake_case, to_camel_case


def test_to_snake_case_strips_angle_brackets():
# aaz-dev-tools#562: generic type names carry angle brackets that must not leak into identifiers.
result = to_snake_case("Record<UserAssignedIdentityResourceId>")
assert "<" not in result and ">" not in result
# the produced attribute/method name must be a valid Python identifier
ast.parse(f"_args_{result} = None")


def test_generic_cls_name_produces_valid_identifier():
# mirrors the code generator: cls name -> "_args_" + snake_case must be assignable Python.
cls_name = to_camel_case("Record<UserAssignedIdentityResourceId>") + "_CreateOrUpdate_create"
for prefix in ("_args_", "_build_args_", "_schema_", "_build_schema_"):
ast.parse(f"{prefix}{to_snake_case(cls_name)} = None")
Loading
Loading