bertjwregeer: @mmerickel This one's for you!
diff --git a/webob/response.py b/webob/response.py
index a5b7bfd..607ac34 100644
--- a/webob/response.py
+++ b/webob/response.py
@@ -155,12 +155,17 @@ class Response(object):
           set to True so that all ``Response`` objects will attempt to check
           the original request for conditional response headers. See
           :meth:`~Response.conditional_response_app` for more information.
+
+        * ``default_body_encoding`` is set to 'UTF-8' by default, it exists to
+          allow users to get/set the Response object using .text, even if no
+          charset has been set for the Content-Type.
     """
 
     default_content_type = 'text/html'
     default_charset = 'UTF-8'
     unicode_errors = 'strict'
     default_conditional_response = False
+    default_body_encoding = 'UTF-8'
 
     # These two are only around so that when people pass them into the
     # constructor they correctly get saved and set, however they are not used
@@ -556,24 +561,30 @@ class Response(object):
 
     def _text__get(self):
         """
-        Get/set the text value of the body (using the charset of the
-        Content-Type)
+        Get/set the text value of the body using the charset of the
+        Content-Type or the default_body_encoding.
         """
-        if not self.charset:
+        if not self.charset and not self.default_body_encoding:
             raise AttributeError(
-                "You cannot access Response.text unless charset is set")
+                "You cannot access Response.text unless charset or default_body_encoding"
+                " is set"
+            )
+        decoding = self.charset or self.default_body_encoding
         body = self.body
-        return body.decode(self.charset, self.unicode_errors)
+        return body.decode(decoding, self.unicode_errors)
 
     def _text__set(self, value):
-        if not self.charset:
+        if not self.charset and not self.default_body_encoding:
             raise AttributeError(
-                "You cannot access Response.text unless charset is set")
+                "You cannot access Response.text unless charset or default_body_encoding"
+                " is set"
+            )
         if not isinstance(value, text_type):
             raise TypeError(
                 "You can only set Response.text to a unicode string "
                 "(not %s)" % type(value))
-        self.body = value.encode(self.charset)
+        encoding = self.charset or self.default_body_encoding
+        self.body = value.encode(encoding)
 
     def _text__del(self):
         del self.body

Add default_body_encoding to Response
Add a new default_body_encoding, this is used for .text if there is no charset for the content-type.

This is a backwards incompatible change that breaks the assumption that setting .text on a binary content-type will raise an error.

This will allow a user to also change the default_body_encoding on the fly before setting the body using .text, thereby allowing the encoding to happen to whatever they wish.
diff --git a/tests/test_response.py b/tests/test_response.py
index 1fdcefe..867d433 100644
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -693,8 +693,13 @@ def test_body_del():
 
 def test_text_get_no_charset():
     res = Response(charset=None)
+    assert '' == res.text
+
+def test_text_get_no_default_body_encoding():
+    res = Response(charset=None)
+    res.default_body_encoding = None
     with pytest.raises(AttributeError):
-        res.__getattribute__('text')
+        assert '' == res.text
 
 def test_unicode_body():
     res = Response()
@@ -717,8 +722,15 @@ def test_text_get_decode():
 def test_text_set_no_charset():
     res = Response()
     res.charset = None
+    res.text = text_('abc')
+    assert res.text == 'abc'
+
+def test_text_set_no_default_body_encoding():
+    res = Response()
+    res.charset = None
+    res.default_body_encoding = None
     with pytest.raises(AttributeError):
-        res.__setattr__('text', 'abc')
+        res.text = text_('abc')
 
 def test_text_set_not_unicode():
     res = Response()

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov coverage
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
coverage==7.8.0
exceptiongroup==1.2.2
iniconfig==2.1.0
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
pytest-cov==6.0.0
tomli==2.2.1
-e git+https://github.com/Pylons/webob.git@ce1eed59e9101c3732295bc8745ba003c700fd0c#egg=WebOb

name: webob
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - tomli==2.2.1
prefix: /opt/conda/envs/webob

tests/test_response.py::test_text_get_no_charset tests/test_response.py::test_text_set_no_charset
tests/test_response.py::test_response_file_body_tell
tests/test_response.py::test_response tests/test_response.py::test_set_response_status_binary tests/test_response.py::test_set_response_status_str_no_reason tests/test_response.py::test_set_response_status_str_generic_reason tests/test_response.py::test_set_response_status_code tests/test_response.py::test_set_response_status_bad tests/test_response.py::test_set_response_status_code_generic_reason tests/test_response.py::test_content_type tests/test_response.py::test_init_content_type_w_charset tests/test_response.py::test_init_adds_default_charset_when_not_json tests/test_response.py::test_init_no_charset_when_json tests/test_response.py::test_init_keeps_specified_charset_when_json tests/test_response.py::test_init_doesnt_add_default_content_type_with_bodyless_status tests/test_response.py::test_cookies tests/test_response.py::test_unicode_cookies_error_raised tests/test_response.py::test_unicode_cookies_warning_issued tests/test_response.py::test_cookies_warning_issued_backwards_compat tests/test_response.py::test_cookies_raises_typeerror tests/test_response.py::test_http_only_cookie tests/test_response.py::test_headers tests/test_response.py::test_response_copy tests/test_response.py::test_response_copy_content_md5 tests/test_response.py::test_HEAD_closes tests/test_response.py::test_HEAD_conditional_response_returns_empty_response tests/test_response.py::test_HEAD_conditional_response_range_empty_response tests/test_response.py::test_conditional_response_if_none_match_false tests/test_response.py::test_conditional_response_if_none_match_true tests/test_response.py::test_conditional_response_if_none_match_weak tests/test_response.py::test_conditional_response_if_modified_since_false tests/test_response.py::test_conditional_response_if_modified_since_true tests/test_response.py::test_conditional_response_range_not_satisfiable_response tests/test_response.py::test_HEAD_conditional_response_range_not_satisfiable_response tests/test_response.py::test_md5_etag tests/test_response.py::test_md5_etag_set_content_md5 tests/test_response.py::test_decode_content_defaults_to_identity tests/test_response.py::test_decode_content_with_deflate tests/test_response.py::test_content_length tests/test_response.py::test_app_iter_range tests/test_response.py::test_app_iter_range_inner_method tests/test_response.py::test_has_body tests/test_response.py::test_content_type_in_headerlist tests/test_response.py::test_str_crlf tests/test_response.py::test_from_file tests/test_response.py::test_from_file2 tests/test_response.py::test_from_text_file tests/test_response.py::test_from_file_w_leading_space_in_header tests/test_response.py::test_file_bad_header tests/test_response.py::test_from_file_not_unicode_headers tests/test_response.py::test_file_with_http_version tests/test_response.py::test_file_with_http_version_more_status tests/test_response.py::test_set_status tests/test_response.py::test_set_headerlist tests/test_response.py::test_request_uri_no_script_name tests/test_response.py::test_request_uri_https tests/test_response.py::test_app_iter_range_starts_after_iter_end tests/test_response.py::test_resp_write_app_iter_non_list tests/test_response.py::test_response_file_body_writelines tests/test_response.py::test_response_file_body_tell_text tests/test_response.py::test_response_write_non_str tests/test_response.py::test_response_file_body_write_empty_app_iter tests/test_response.py::test_response_file_body_write_empty_body tests/test_response.py::test_response_file_body_close_not_implemented tests/test_response.py::test_response_file_body_repr tests/test_response.py::test_body_get_is_none tests/test_response.py::test_body_get_is_unicode_notverylong tests/test_response.py::test_body_get_is_unicode tests/test_response.py::test_body_set_not_unicode_or_str tests/test_response.py::test_body_set_unicode tests/test_response.py::test_body_set_under_body_doesnt_exist tests/test_response.py::test_body_del tests/test_response.py::test_text_get_no_default_body_encoding tests/test_response.py::test_unicode_body tests/test_response.py::test_text_get_decode tests/test_response.py::test_text_set_no_default_body_encoding tests/test_response.py::test_text_set_not_unicode tests/test_response.py::test_text_del tests/test_response.py::test_body_file_del tests/test_response.py::test_write_unicode tests/test_response.py::test_write_unicode_no_charset tests/test_response.py::test_write_text tests/test_response.py::test_app_iter_del tests/test_response.py::test_charset_set_no_content_type_header tests/test_response.py::test_charset_del_no_content_type_header tests/test_response.py::test_content_type_params_get_no_semicolon_in_content_type_header tests/test_response.py::test_content_type_params_get_semicolon_in_content_type_header tests/test_response.py::test_content_type_params_set_value_dict_empty tests/test_response.py::test_content_type_params_set_ok_param_quoting tests/test_response.py::test_charset_delete tests/test_response.py::test_set_cookie_overwrite tests/test_response.py::test_set_cookie_value_is_None tests/test_response.py::test_set_cookie_expires_is_None_and_max_age_is_int tests/test_response.py::test_set_cookie_expires_is_None_and_max_age_is_timedelta tests/test_response.py::test_set_cookie_expires_is_not_None_and_max_age_is_None tests/test_response.py::test_set_cookie_expires_is_timedelta_and_max_age_is_None tests/test_response.py::test_delete_cookie tests/test_response.py::test_delete_cookie_with_path tests/test_response.py::test_delete_cookie_with_domain tests/test_response.py::test_unset_cookie_not_existing_and_not_strict tests/test_response.py::test_unset_cookie_not_existing_and_strict tests/test_response.py::test_unset_cookie_key_in_cookies tests/test_response.py::test_merge_cookies_no_set_cookie tests/test_response.py::test_merge_cookies_resp_is_Response tests/test_response.py::test_merge_cookies_resp_is_wsgi_callable tests/test_response.py::test_body_get_body_is_None_len_app_iter_is_zero tests/test_response.py::test_cache_control_get tests/test_response.py::test_location tests/test_response.py::test_request_uri_http tests/test_response.py::test_request_uri_no_script_name2 tests/test_response.py::test_cache_control_object_max_age_ten tests/test_response.py::test_cache_control_set_object_error tests/test_response.py::test_cache_expires_set tests/test_response.py::test_status_code_set tests/test_response.py::test_cache_control_set_dict tests/test_response.py::test_cache_control_set_None tests/test_response.py::test_cache_control_set_unicode tests/test_response.py::test_cache_control_set_control_obj_is_not_None tests/test_response.py::test_cache_control_del tests/test_response.py::test_body_file_get tests/test_response.py::test_body_file_write_no_charset tests/test_response.py::test_body_file_write_unicode_encodes tests/test_response.py::test_repr tests/test_response.py::test_cache_expires_set_timedelta tests/test_response.py::test_cache_expires_set_int tests/test_response.py::test_cache_expires_set_None tests/test_response.py::test_cache_expires_set_zero tests/test_response.py::test_encode_content_unknown tests/test_response.py::test_encode_content_identity tests/test_response.py::test_encode_content_gzip_already_gzipped tests/test_response.py::test_encode_content_gzip_notyet_gzipped tests/test_response.py::test_encode_content_gzip_notyet_gzipped_lazy tests/test_response.py::test_encode_content_gzip_buffer_coverage tests/test_response.py::test_decode_content_identity tests/test_response.py::test_decode_content_weird tests/test_response.py::test_decode_content_gzip tests/test_response.py::test__abs_headerlist_location_with_scheme tests/test_response.py::test__abs_headerlist_location_no_scheme tests/test_response.py::test_response_set_body_file1 tests/test_response.py::test_response_set_body_file2 tests/test_response.py::test_response_json_body tests/test_response.py::test_cache_expires_set_zero_then_nonzero
webob/response.py
webob/response.py