Repository: Pylons/pyramid

Issue:
render_to_response should not affect request.response
Related to #1534 and #1111 (3803d92fc0da5282656d4229e063a1908ec5522a).

I think that `render_to_response` should use a new response object acquired from the `IResponseFactory`. This should be possible in the same way as #1111 where a new response is created/returned and then `request.response` is restored to its pervious value.

In reality the renderer api should accept an optional `response` object that they can mutate instead of having them mutate `request.response` but this is where we are.



Discussion:
+1 for this idea.

The reservation I have is that the docs currently show (via the pseudo-official cookbook) that a developer should be expecting/mutating the request.response when defining custom renderers.  When compared with the official shipped renderers, I can't think of a reason why a developer would think otherwise -- so I think this would probably break any existing custom renderers and should be integrated with caution.    I definitely think this approach should be integrated though.
- http://docs.pylonsproject.org/projects/pyramid-cookbook/en/latest/templates/customrenderers.html

I think you'll find that it won't break anything if you look at the implementation in https://github.com/Pylons/pyramid/commit/3803d92fc0da5282656d4229e063a1908ec5522a.

Specifically the only thing that would break is people who expect `render_to_response` to return the same thing as `request.response` which I'm claiming is silly.

`request.response` is an object that is used by automated renderers when returning a dict from a view, and I wouldn't (don't) expect it to be used when calling `render_to_response`.

I agree it's silly, but I think the docs and sample code teach people otherwise.

That being said, I'm enthusiastically for this behavior change -- I just wanted to note up a potential incompatibility.  So I'm just going to agree with you blindly !


Resolving patch:
diff --git a/pyramid/renderers.py b/pyramid/renderers.py
--- a/pyramid/renderers.py
+++ b/pyramid/renderers.py
@@ -1,3 +1,4 @@
+import contextlib
 import json
 import os
 
@@ -73,24 +74,16 @@ def render(renderer_name, value, request=None, package=None):
     helper = RendererHelper(name=renderer_name, package=package,
                             registry=registry)
 
-    saved_response = None
-    # save the current response, preventing the renderer from affecting it
-    attrs = request.__dict__ if request is not None else {}
-    if 'response' in attrs:
-        saved_response = attrs['response']
-        del attrs['response']
-
-    result = helper.render(value, None, request=request)
-
-    # restore the original response, overwriting any changes
-    if saved_response is not None:
-        attrs['response'] = saved_response
-    elif 'response' in attrs:
-        del attrs['response']
+    with temporary_response(request):
+        result = helper.render(value, None, request=request)
 
     return result
 
-def render_to_response(renderer_name, value, request=None, package=None):
+def render_to_response(renderer_name,
+                       value,
+                       request=None,
+                       package=None,
+                       response=None):
     """ Using the renderer ``renderer_name`` (a template
     or a static renderer), render the value (or set of values) using
     the result of the renderer's ``__call__`` method (usually a string
@@ -121,9 +114,16 @@ def render_to_response(renderer_name, value, request=None, package=None):
 
     Supply a ``request`` parameter in order to provide the renderer
     with the most correct 'system' values (``request`` and ``context``
-    in particular). Keep in mind that if the ``request`` parameter is
-    not passed in, any changes to ``request.response`` attributes made
-    before calling this function will be ignored.
+    in particular). Keep in mind that any changes made to ``request.response``
+    prior to calling this function will not be reflected in the resulting
+    response object. A new response object will be created for each call
+    unless one is passed as the ``response`` argument.
+
+    .. versionchanged:: 1.6
+       In previous versions, any changes made to ``request.response`` outside
+       of this function call would affect the returned response. This is no
+       longer the case. If you wish to send in a pre-initialized response
+       then you may pass one in the ``response`` argument.
 
     """
     try:
@@ -134,7 +134,33 @@ def render_to_response(renderer_name, value, request=None, package=None):
         package = caller_package()
     helper = RendererHelper(name=renderer_name, package=package,
                             registry=registry)
-    return helper.render_to_response(value, None, request=request)
+
+    with temporary_response(request):
+        if response is not None:
+            request.response = response
+        result = helper.render_to_response(value, None, request=request)
+
+    return result
+
+@contextlib.contextmanager
+def temporary_response(request):
+    """
+    Temporarily delete request.response and restore it afterward.
+    """
+    saved_response = None
+    # save the current response, preventing the renderer from affecting it
+    attrs = request.__dict__ if request is not None else {}
+    if 'response' in attrs:
+        saved_response = attrs['response']
+        del attrs['response']
+
+    yield
+
+    # restore the original response, overwriting any changes
+    if saved_response is not None:
+        attrs['response'] = saved_response
+    elif 'response' in attrs:
+        del attrs['response']
 
 def get_renderer(renderer_name, package=None):
     """ Return the renderer object for the renderer ``renderer_name``.


Test patch:
diff --git a/pyramid/tests/test_renderers.py b/pyramid/tests/test_renderers.py
--- a/pyramid/tests/test_renderers.py
+++ b/pyramid/tests/test_renderers.py
@@ -517,10 +517,11 @@ def setUp(self):
     def tearDown(self):
         testing.tearDown()
 
-    def _callFUT(self, renderer_name, value, request=None, package=None):
+    def _callFUT(self, renderer_name, value, request=None, package=None,
+                 response=None):
         from pyramid.renderers import render_to_response
         return render_to_response(renderer_name, value, request=request,
-                                  package=package)
+                                  package=package, response=response)
 
     def test_it_no_request(self):
         renderer = self.config.testing_add_renderer(
@@ -554,6 +555,43 @@ def test_it_with_package(self):
         renderer.assert_(a=1)
         renderer.assert_(request=request)
 
+    def test_response_preserved(self):
+        request = testing.DummyRequest()
+        response = object() # should error if mutated
+        request.response = response
+        # use a json renderer, which will mutate the response
+        result = self._callFUT('json', dict(a=1), request=request)
+        self.assertEqual(result.body, b'{"a": 1}')
+        self.assertNotEqual(request.response, result)
+        self.assertEqual(request.response, response)
+
+    def test_no_response_to_preserve(self):
+        from pyramid.decorator import reify
+        class DummyRequestWithClassResponse(object):
+            _response = DummyResponse()
+            _response.content_type = None
+            _response.default_content_type = None
+            @reify
+            def response(self):
+                return self._response
+        request = DummyRequestWithClassResponse()
+        # use a json renderer, which will mutate the response
+        result = self._callFUT('json', dict(a=1), request=request)
+        self.assertEqual(result.body, b'{"a": 1}')
+        self.assertFalse('response' in request.__dict__)
+
+    def test_custom_response_object(self):
+        class DummyRequestWithClassResponse(object):
+            pass
+        request = DummyRequestWithClassResponse()
+        response = DummyResponse()
+        # use a json renderer, which will mutate the response
+        result = self._callFUT('json', dict(a=1), request=request,
+                               response=response)
+        self.assertTrue(result is response)
+        self.assertEqual(result.body, b'{"a": 1}')
+        self.assertFalse('response' in request.__dict__)
+
 class Test_get_renderer(unittest.TestCase):
     def setUp(self):
         self.config = testing.setUp()
@@ -614,7 +652,14 @@ class Dummy:
 
 class DummyResponse:
     status = '200 OK'
+    default_content_type = 'text/html'
+    content_type = default_content_type
     headerlist = ()
     app_iter = ()
-    body = ''
+    body = b''
+
+    # compat for renderer that will set unicode on py3
+    def _set_text(self, val): # pragma: no cover
+        self.body = val.encode('utf8')
+    text = property(fset=_set_text)