From 027704d5133a30fc788d5120d6577d2edc41cd59 Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Mon, 31 Aug 2026 23:04:16 +0530 Subject: [PATCH 1/3] Escape override rename keys when generating structure/unstructure code The generated (un)structuring code interpolated a field's rename target into the source inside single quotes (o['{kn}'], res['{kn}'], etc). A rename key is an arbitrary dict key, so one containing a quote (for example "it's") produced invalid source and raised SyntaxError. Interpolate the key with repr ({kn!r}) so any key string is embedded safely. --- src/cattrs/gen/__init__.py | 48 +++++++++++++++++++------------------- tests/test_gen_dict.py | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index f558b1f6..d5ca4fd6 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -218,11 +218,11 @@ def make_dict_unstructure_fn_from_attrs( internal_arg_parts[def_name] = c(d) lines.append(f" if instance.{attr_name} != {def_str}:") - lines.append(f" res['{kn}'] = {invoke}") + lines.append(f" res[{kn!r}] = {invoke}") else: # No default or no override. - invocation_lines.append(f"'{kn}': {invoke},") + invocation_lines.append(f"{kn!r}: {invoke},") internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts]) if internal_arg_line: @@ -469,7 +469,7 @@ def make_dict_structure_fn_from_attrs( if not a.init: if a.default is not NOTHING: - pi_lines.append(f"{i}if '{kn}' in o:") + pi_lines.append(f"{i}if {kn!r} in o:") i = f"{i} " pi_lines.append(f"{i}try:") i = f"{i} " @@ -479,16 +479,16 @@ def make_dict_structure_fn_from_attrs( if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t pi_lines.append( - f"{i}instance.{an} = {struct_handler_name}(o['{kn}'])" + f"{i}instance.{an} = {struct_handler_name}(o[{kn!r}])" ) else: tn = f"__c_type_{an}" internal_arg_parts[tn] = t pi_lines.append( - f"{i}instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" + f"{i}instance.{an} = {struct_handler_name}(o[{kn!r}], {tn})" ) else: - pi_lines.append(f"{i}instance.{an} = o['{kn}']") + pi_lines.append(f"{i}instance.{an} = o[{kn!r}]") i = i[:-2] pi_lines.append(f"{i}except Exception as e:") i = f"{i} " @@ -499,7 +499,7 @@ def make_dict_structure_fn_from_attrs( else: if a.default is not NOTHING: - lines.append(f"{i}if '{kn}' in o:") + lines.append(f"{i}if {kn!r} in o:") i = f"{i} " lines.append(f"{i}try:") i = f"{i} " @@ -509,14 +509,14 @@ def make_dict_structure_fn_from_attrs( if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t lines.append( - f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'])" + f"{i}res['{ian}'] = {struct_handler_name}(o[{kn!r}])" ) else: lines.append( - f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {type_name})" + f"{i}res['{ian}'] = {struct_handler_name}(o[{kn!r}], {type_name})" ) else: - lines.append(f"{i}res['{ian}'] = o['{kn}']") + lines.append(f"{i}res['{ian}'] = o[{kn!r}]") i = i[:-2] lines.append(f"{i}except Exception as e:") i = f"{i} " @@ -610,15 +610,15 @@ def make_dict_structure_fn_from_attrs( internal_arg_parts[struct_handler_name] = handler if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t - pi_line = f" instance.{an} = {struct_handler_name}(o['{kn}'])" + pi_line = f" instance.{an} = {struct_handler_name}(o[{kn!r}])" else: tn = f"__c_type_{an}" internal_arg_parts[tn] = t pi_line = ( - f" instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" + f" instance.{an} = {struct_handler_name}(o[{kn!r}], {tn})" ) else: - pi_line = f" instance.{an} = o['{kn}']" + pi_line = f" instance.{an} = o[{kn!r}]" pi_lines.append(pi_line) else: @@ -627,13 +627,13 @@ def make_dict_structure_fn_from_attrs( internal_arg_parts[struct_handler_name] = handler if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t - invocation_line = f"{struct_handler_name}(o['{kn}'])," + invocation_line = f"{struct_handler_name}(o[{kn!r}])," else: tn = f"__c_type_{an}" internal_arg_parts[tn] = t - invocation_line = f"{struct_handler_name}(o['{kn}'], {tn})," + invocation_line = f"{struct_handler_name}(o[{kn!r}], {tn})," else: - invocation_line = f"o['{kn}']," + invocation_line = f"o[{kn!r}]," if a.kw_only: invocation_line = f"{a.alias}={invocation_line}" @@ -675,37 +675,37 @@ def make_dict_structure_fn_from_attrs( kn = override.rename allowed_fields.add(kn) if not a.init: - pi_lines.append(f" if '{kn}' in o:") + pi_lines.append(f" if {kn!r} in o:") if handler: if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t pi_lines.append( - f" instance.{an} = {struct_handler_name}(o['{kn}'])" + f" instance.{an} = {struct_handler_name}(o[{kn!r}])" ) else: tn = f"__c_type_{an}" internal_arg_parts[tn] = t pi_lines.append( - f" instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" + f" instance.{an} = {struct_handler_name}(o[{kn!r}], {tn})" ) else: - pi_lines.append(f" instance.{an} = o['{kn}']") + pi_lines.append(f" instance.{an} = o[{kn!r}]") else: - post_lines.append(f" if '{kn}' in o:") + post_lines.append(f" if {kn!r} in o:") if handler: if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t post_lines.append( - f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'])" + f" res['{a.alias}'] = {struct_handler_name}(o[{kn!r}])" ) else: tn = f"__c_type_{an}" internal_arg_parts[tn] = t post_lines.append( - f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'], {tn})" + f" res['{a.alias}'] = {struct_handler_name}(o[{kn!r}], {tn})" ) else: - post_lines.append(f" res['{a.alias}'] = o['{kn}']") + post_lines.append(f" res['{a.alias}'] = o[{kn!r}]") if not pi_lines: instantiation_lines = ( [" return __cl("] diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index f282ddb2..40f46bd7 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -253,6 +253,30 @@ class A: assert cve.value.exceptions[1].extra_fields == {"c"} +def test_renaming_to_key_with_special_characters(): + """A rename target is a dict key, so it may legitimately contain quotes. + + It used to be interpolated into the generated code inside single quotes, so + a key like ``it's`` produced invalid source and raised ``SyntaxError``. + """ + + @define + class A: + b: int + + for key in ("it's", 'a"b', "a'] or 1 or ['b"): + converter = Converter() + converter.register_unstructure_hook( + A, make_dict_unstructure_fn(A, converter, b=override(rename=key)) + ) + converter.register_structure_hook( + A, make_dict_structure_fn(A, converter, b=override(rename=key)) + ) + + assert converter.unstructure(A(1)) == {key: 1} + assert converter.structure({key: 1}, A) == A(1) + + def test_omitting(converter: BaseConverter): """Omitting works.""" From df3e28f823c4c1f0186f6cd36d5b405adcb17f0e Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Mon, 31 Aug 2026 23:05:03 +0530 Subject: [PATCH 2/3] Add HISTORY entry for the rename-key escaping fix --- HISTORY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index e7b91f71..f16afc17 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT (UNRELEASED) +- Fix `override(rename=...)` targets containing a quote (or other characters not safe in a bare string literal) crashing code generation with `SyntaxError`; the rename key is now embedded with `repr`. + ([#771](https://github.com/python-attrs/cattrs/pull/771)) - Fix `Counter` keys not being unstructured with the key type's own hook; the single-type-arg branch passed the whole type-args tuple to the key hook lookup instead of the key type. ([#768](https://github.com/python-attrs/cattrs/pull/768)) - Fix `create_default_dis_func ` (aka `create_uniq_field_dis_func`) failing to disambiguate valid unions depending on the order of the member classes; unique fields are now resolved iteratively to a fixpoint. From 9ff7d7a31e30b54666426e9dd7cbfc928a42d274 Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Tue, 1 Sep 2026 13:35:00 +0530 Subject: [PATCH 3/3] Parametrize the special-characters rename test --- tests/test_gen_dict.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 40f46bd7..5e9e8f0b 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -253,7 +253,8 @@ class A: assert cve.value.exceptions[1].extra_fields == {"c"} -def test_renaming_to_key_with_special_characters(): +@pytest.mark.parametrize("key", ["it's", 'a"b', "a'] or 1 or ['b"]) +def test_renaming_to_key_with_special_characters(key): """A rename target is a dict key, so it may legitimately contain quotes. It used to be interpolated into the generated code inside single quotes, so @@ -264,17 +265,16 @@ def test_renaming_to_key_with_special_characters(): class A: b: int - for key in ("it's", 'a"b', "a'] or 1 or ['b"): - converter = Converter() - converter.register_unstructure_hook( - A, make_dict_unstructure_fn(A, converter, b=override(rename=key)) - ) - converter.register_structure_hook( - A, make_dict_structure_fn(A, converter, b=override(rename=key)) - ) - - assert converter.unstructure(A(1)) == {key: 1} - assert converter.structure({key: 1}, A) == A(1) + converter = Converter() + converter.register_unstructure_hook( + A, make_dict_unstructure_fn(A, converter, b=override(rename=key)) + ) + converter.register_structure_hook( + A, make_dict_structure_fn(A, converter, b=override(rename=key)) + ) + + assert converter.unstructure(A(1)) == {key: 1} + assert converter.structure({key: 1}, A) == A(1) def test_omitting(converter: BaseConverter):