diff --git a/b2/_internal/_cli/obj_loads.py b/b2/_internal/_cli/obj_loads.py index ea8f0b935..4a75af0a8 100644 --- a/b2/_internal/_cli/obj_loads.py +++ b/b2/_internal/_cli/obj_loads.py @@ -57,7 +57,13 @@ def describe_type(type_) -> str: def type_with_config(type_: type[T], config: pydantic.ConfigDict) -> type[T]: type_ = copy.copy(type_) if not hasattr(type_, '__config__'): - type_.__pydantic_config__ = config + try: + type_.__pydantic_config__ = config + except AttributeError: + # parametrized builtin generics (e.g. `list[LifecycleRule]`) are `types.GenericAlias` objects, + # which do not accept new attributes; use the type as is, `validated_loads` passes the config + # to `TypeAdapter` directly in that case + pass else: type_.__config__ = type_.__config__.copy() type_.__config__.update(config) @@ -67,9 +73,13 @@ def type_with_config(type_: type[T], config: pydantic.ConfigDict) -> type[T]: def validated_loads(data: str, expected_type: type[T] | None = None) -> T: val = _UNDEF if expected_type is not None and pydantic is not None: - expected_type = type_with_config(expected_type, pydantic.ConfigDict(extra='allow')) + config = pydantic.ConfigDict(extra='allow') + expected_type = type_with_config(expected_type, config) + has_config = hasattr(expected_type, '__pydantic_config__') or hasattr( + expected_type, '__config__' + ) try: - ta = TypeAdapter(expected_type) + ta = TypeAdapter(expected_type, config=None if has_config else config) except TypeError: # TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' # This is thrown on python<3.10 even with eval_type_backport diff --git a/changelog.d/+pydantic-generic-alias.fixed.md b/changelog.d/+pydantic-generic-alias.fixed.md new file mode 100644 index 000000000..7bfa3199d --- /dev/null +++ b/changelog.d/+pydantic-generic-alias.fixed.md @@ -0,0 +1 @@ +Fix `b2 bucket create` and `b2 bucket update` with the deprecated `--lifecycle-rules` option (and other JSON options typed as a parametrized list) failing with `AttributeError: 'types.GenericAlias' object has no attribute '__pydantic_config__'` when the `full` extra (pydantic) is installed. diff --git a/test/unit/_cli/test_obj_loads.py b/test/unit/_cli/test_obj_loads.py index f92101452..f0502df6c 100644 --- a/test/unit/_cli/test_obj_loads.py +++ b/test/unit/_cli/test_obj_loads.py @@ -66,3 +66,18 @@ def test_validated_loads__typed_dict_types_validation(typed_dict_cls): input_ = '{"a": "abc", "b": 2}' with pytest.raises(argparse.ArgumentTypeError): validated_loads(input_, typed_dict_cls) + + +@pytest.mark.skipif(pydantic is None, reason='pydantic is not enabled') +def test_validated_loads__parametrized_generic(typed_dict_cls): + # `list[...]` is a `types.GenericAlias`, which does not accept new attributes; extra keys must be kept + input_ = '[{"a": 1, "b": "2", "extra": null}]' + expected_val = [{'a': 1, 'b': '2', 'extra': None}] + assert validated_loads(input_, list[typed_dict_cls]) == expected_val + + +@pytest.mark.skipif(pydantic is None, reason='pydantic is not enabled') +def test_validated_loads__parametrized_generic_types_validation(typed_dict_cls): + input_ = '[{"a": "abc", "b": 2}]' + with pytest.raises(argparse.ArgumentTypeError): + validated_loads(input_, list[typed_dict_cls])