Opened 4 months ago

Last modified 2 months ago

#36916 assigned New feature

Add support for streaming with TaskGroups

Reported by: Thomas Grainger Owned by: Carlton Gibson
Component: HTTP handling Version: dev
Severity: Normal Keywords: structured-concurrency, taskgroups
Cc: Thomas Grainger Triage Stage: Accepted
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Thomas Grainger)

https://forum.djangoproject.com/t/streamingresponse-driven-by-a-taskgroup/40320/4
https://github.com/django/new-features/issues/117

Feature Description

see https://forum.djangoproject.com/t/streamingresponse-driven-by-a-taskgroup/40320/4

I’d like to be able to write code that combines multiple streams of data:

async def news_and_weather(request: HttpRequest) -> StreamingHttpResponse:
    async def gen() -> AsyncGenerator[bytes]:
        async def push(ws_url: str, tx: MemoryObjectSendStream) -> None:
            async with tx, connect_ws(ws_url) as conn:
                async for msg in conn:
                    await tx.send(msg)

        async with anyio.create_task_group() as tg:
            tx, rx =  anyio.create_memory_object_stream[bytes]()
            with tx, rx:
                tg.start_soon(push, "ws://example.com/news", tx.clone())
                tg.start_soon(push, "ws://example.com/weather", tx.clone())
                tx.close()
                async for msg in rx:
                    yield msg  # yield in async generator!! illegal inside TaskGroup!
    return StreamingHttpResponse(gen())

Problem

however this doesn’t work because I’m using a yield inside an async generator that’s not a context manager, and calling aclosing() on that async generator is not sufficient to allow a TaskGroup to cancel itself and catch the cancel error.

from useful_types import SupportsAnext

class AsyncIteratorBytesResource(Protocol):
    """
    all the machinery needed to safely run an AsyncGenerator[Bytes]

    (for django-stubs) this allows AsyncGenerator[bytes] but is less strict
    so would also allow a anyio MemoryObjectRecieveStream[bytes]]
    """

    async def __aiter__(self) -> SupportsAnext[bytes]: ...
    async def aclose(self) -> object: ...


async def news_and_weather(request: HttpRequest) -> StreamingAcmgrHttpResponse:
    @contextlib.asynccontextmanager
    async def acmgr_gen() -> AsyncGenerator[AsyncIteratorBytesResource]:
        async def push(ws_url: str, tx: MemoryObjectSendStream) -> None:
            async with tx, connect_ws(ws_url) as conn:
                async for msg in conn:
                    await tx.send(msg)

        async with anyio.create_task_group() as tg:
            tx, rx =  anyio.create_memory_object_stream[bytes]()
            with tx, rx:
                tg.start_soon(push, "ws://example.com/news", tx.clone())
                tg.start_soon(push, "ws://example.com/weather", tx.clone())
                tx.close()
                yield rx  # yield inside asynccontextmanager, permitted inside TaskGroup

    return StreamingAcmgrHttpResponse(acmgr_gen())

Implementation Suggestions

https://github.com/django/django/pull/19364/changes

Change History (10)

comment:1 by Amar, 4 months ago

Description: modified (diff)
Owner: set to Amar
Status: newassigned

comment:2 by Amar, 4 months ago

Owner: Amar removed
Status: assignednew

comment:3 by Thomas Grainger, 4 months ago

Description: modified (diff)

comment:4 by Thomas Grainger, 4 months ago

Needs documentation: set

comment:5 by Jacob Walls, 4 months ago

Easy pickings: unset
Owner: set to Thomas Grainger
Status: newassigned
Summary: add support for streaming with TaskGroupsAdd support for streaming with TaskGroups
Triage Stage: UnreviewedAccepted
Type: UncategorizedNew feature

Thanks. Reception on the new-features repo issue seems positive, so though it hasn't moved through any swimlanes there yet, I will speculatively move this one to Accepted assuming it likely will.

comment:6 by Carlton Gibson, 3 months ago

Patch needs improvement: set

General approach looks good. I left some comments, and there’s some discussion about a possible alternative approach (but I’m not sure if that’s viable or not, so we should see if that appears.)

comment:7 by Thomas Grainger, 2 months ago

Has patch: unset
Patch needs improvement: unset

comment:8 by Thomas Grainger, 2 months ago

How about this approach? Working from my phone so posting as a patch rather than pushing directly.

Summary of changes (from Claude)

django/http/response.py

  • _set_streaming_content detects async context managers via hasattr(__aenter__, __aexit__), sets self.is_acmgr = True and stores the value in self.__acmgr
  • streaming_content property: is_acmgr is an early return at the top (unnested from is_async). Returns an @asynccontextmanager when is_acmgr is True — callers that check is_acmgr use async with response.streaming_content as agen. Both acmgr and regular async paths apply make_bytes and aclose their underlying iterator in a finally block
  • StreamingHttpResponse gains __aenter__/__aexit__: for acmgr responses, __aenter__ calls into self.streaming_content (no duplication of awrapper logic needed), stores the context and the yielded generator, then __aexit__ closes the generator and exits the context in the right order so TaskGroup cleans up correctly
  • __iter__, __aiter__, and getvalue all raise IsAcmgrException when is_acmgr is True
  • IsAcmgrException is exported from django.http

django/core/handlers/asgi.py

  • send_response simplified to async with response as content — works for both regular streaming and acmgr responses. aclosing no longer needed

django/middleware/gzip.py

  • Adds is_acmgr branch: captures response.streaming_content (an acmgr) and wraps it in a new @asynccontextmanager that feeds the yielded generator into acompress_sequence

django/utils/text.py

  • acompress_sequence wraps its entire body in try/finally to aclose its operand on exit if the method is present
  • django/core/handlers/asgi.py

    diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
    index 9555860..0f55717 100644
    a b import sys  
    44import tempfile
    55import traceback
    66from collections import defaultdict
    7 from contextlib import aclosing, closing
     7from contextlib import closing
    88
    99from asgiref.sync import ThreadSensitiveContext, sync_to_async
    1010
    class ASGIHandler(base.BaseHandler):  
    315315        )
    316316        # Streaming responses need to be pinned to their iterator.
    317317        if response.streaming:
    318             # - Consume via `__aiter__` and not `streaming_content` directly,
    319             #   to allow mapping of a sync iterator.
    320             # - Use aclosing() when consuming aiter. See
    321             #   https://github.com/python/cpython/commit/6e8dcdaaa49d4313bf9fab9f9923ca5828fbb10e
    322             async with aclosing(aiter(response)) as content:
     318            async with response as content:
    323319                async for part in content:
    324320                    for chunk, _ in self.chunk_bytes(part):
    325321                        await send(
  • django/http/__init__.py

    diff --git a/django/http/__init__.py b/django/http/__init__.py
    index 628564e..2df8fa6 100644
    a b from django.http.request import (  
    88)
    99from django.http.response import (
    1010    BadHeaderError,
     11
    1112    FileResponse,
    1213    Http404,
    1314    HttpResponse,
    __all__ = [  
    4748    "HttpResponseServerError",
    4849    "Http404",
    4950    "BadHeaderError",
     51
    5052    "JsonResponse",
    5153    "FileResponse",
    5254]
  • django/http/response.py

    diff --git a/django/http/response.py b/django/http/response.py
    index 9bf0b14..e3976da 100644
    a b import re  
    77import sys
    88import time
    99import warnings
     10
    1011from email.header import Header
    1112from http.client import responses
    1213from urllib.parse import urlsplit
    class BadHeaderError(ValueError):  
    104105    pass
    105106
    106107
     108
     109
     110
     111
    107112class HttpResponseBase:
    108113    """
    109114    An HTTP response base class with dictionary-accessed headers.
    class StreamingHttpResponse(HttpResponseBase):  
    479484
    480485    @property
    481486    def streaming_content(self):
     487
     488
     489
     490
     491
     492
     493
     494
     495
     496
     497
     498
     499
     500
     501
     502
     503
     504
    482505        if self.is_async:
    483506            # pull to lexical scope to capture fixed reference in case
    484507            # streaming_content is set again later.
    485508            _iterator = self._iterator
    486509
    487510            async def awrapper():
    488                 async for part in _iterator:
    489                     yield self.make_bytes(part)
     511                try:
     512                    async for part in _iterator:
     513                        yield self.make_bytes(part)
     514                finally:
     515                    if hasattr(_iterator, "aclose"):
     516                        await _iterator.aclose()
    490517
    491518            return awrapper()
    492519        else:
    class StreamingHttpResponse(HttpResponseBase):  
    498525
    499526    def _set_streaming_content(self, value):
    500527        # Ensure we can never iterate on "value" more than once.
     528
     529
     530
     531
     532
     533
    501534        try:
    502535            self._iterator = iter(value)
    503536            self.is_async = False
    class StreamingHttpResponse(HttpResponseBase):  
    507540        if hasattr(value, "close"):
    508541            self._resource_closers.append(value.close)
    509542
     543
     544
     545
     546
     547
     548
     549
     550
     551
     552
     553
     554
     555
     556
     557
     558
    510559    def __iter__(self):
     560
     561
     562
     563
     564
    511565        try:
    512566            return iter(self.streaming_content)
    513567        except TypeError:
    class StreamingHttpResponse(HttpResponseBase):  
    528582            return map(self.make_bytes, iter(async_to_sync(to_list)(self._iterator)))
    529583
    530584    async def __aiter__(self):
     585
     586
     587
     588
     589
    531590        try:
    532591            async for part in self.streaming_content:
    533592                yield part
    class StreamingHttpResponse(HttpResponseBase):  
    544603                yield part
    545604
    546605    def getvalue(self):
     606
     607
     608
     609
     610
    547611        return b"".join(self.streaming_content)
    548612
    549613
  • django/middleware/gzip.py

    diff --git a/django/middleware/gzip.py b/django/middleware/gzip.py
    index eb151d7..78b5739 100644
    a b  
     1
     2
    13from django.utils.cache import patch_vary_headers
    24from django.utils.deprecation import MiddlewareMixin
    35from django.utils.regex_helper import _lazy_re_compile
    class GZipMiddleware(MiddlewareMixin):  
    3133            return response
    3234
    3335        if response.streaming:
    34             if response.is_async:
     36            if response.is_acmgr:
     37                original_acmgr = response.streaming_content
     38                max_random_bytes = self.max_random_bytes
     39
     40                @asynccontextmanager
     41                async def compressed_acmgr():
     42                    async with original_acmgr as agen:
     43                        yield acompress_sequence(
     44                            agen,
     45                            max_random_bytes=max_random_bytes,
     46                        )
     47
     48                response.streaming_content = compressed_acmgr()
     49            elif response.is_async:
    3550                response.streaming_content = acompress_sequence(
    3651                    response.streaming_content,
    3752                    max_random_bytes=self.max_random_bytes,
  • django/utils/text.py

    diff --git a/django/utils/text.py b/django/utils/text.py
    index d1306f9..55bd6f5 100644
    a b def compress_sequence(sequence, *, max_random_bytes=None):  
    390390
    391391
    392392async def acompress_sequence(sequence, *, max_random_bytes=None):
    393     buf = StreamingBuffer()
    394     filename = _get_random_filename(max_random_bytes) if max_random_bytes else None
    395     with GzipFile(
    396         filename=filename, mode="wb", compresslevel=6, fileobj=buf, mtime=0
    397     ) as zfile:
    398         # Output headers...
     393    try:
     394        buf = StreamingBuffer()
     395        filename = _get_random_filename(max_random_bytes) if max_random_bytes else None
     396        with GzipFile(
     397            filename=filename, mode="wb", compresslevel=6, fileobj=buf, mtime=0
     398        ) as zfile:
     399            # Output headers...
     400            yield buf.read()
     401            async for item in sequence:
     402                zfile.write(item)
     403                zfile.flush()
     404                data = buf.read()
     405                if data:
     406                    yield data
    399407        yield buf.read()
    400         async for item in sequence:
    401             zfile.write(item)
    402             zfile.flush()
    403             data = buf.read()
    404             if data:
    405                 yield data
    406     yield buf.read()
     408    finally:
     409        if hasattr(sequence, "aclose"):
     410            await sequence.aclose()

comment:9 by Thomas Grainger, 2 months ago

Attaching updated patch based on the approach discussed in PR #19364.

Summary of changes

django/http/response.py:

  • _set_streaming_content detects async context managers via hasattr(__aenter__, __aexit__), sets is_acmgr = True and stores the value
  • streaming_content property returns an @asynccontextmanager wrapping make_bytes when is_acmgr is True
  • StreamingHttpResponse gains __aenter__/__aexit__: for acmgr responses, enters the streaming_content CM; for regular responses, returns aiter(self). __aexit__ only handles acmgr CM cleanup — aclosing in the ASGI handler handles iterator close
  • __iter__, __aiter__, and getvalue raise IsAcmgrException when is_acmgr is True

django/core/handlers/asgi.py:

  • Single code path: async with response as agen, aclosing(agen) as content: — works for both regular streaming and acmgr responses

django/middleware/gzip.py:

  • Adds is_acmgr branch that wraps the acmgr in a new @asynccontextmanager feeding into acompress_sequence

django/utils/text.py:

  • acompress_sequence wraps its body in try/finally to aclose its operand

Tests

  • 13 new tests in tests/httpwrappers/tests.py covering: basic acmgr streaming, make_bytes coercion, IsAcmgrException guards on __iter__/__aiter__/getvalue, __aexit__ on error and break, non-acmgr __aenter__ fallback, reassignment, single-producer TaskGroup+Queue, and multi-producer fan-in (news-and-weather pattern)
  • 1 new test in tests/middleware/tests.py for gzip compression of acmgr streaming responses

Docs

  • New "Streaming with TaskGroup" section in docs/ref/request-response.txt with full news_and_weather example, key points re PEP 789, and anyio note
  • Release note in docs/releases/6.1.txt

Patch

  • django/core/handlers/asgi.py

    diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
    index 7ee5208..bcdafdc 100644
    a b class ASGIHandler(base.BaseHandler):  
    318318        )
    319319        # Streaming responses need to be pinned to their iterator.
    320320        if response.streaming:
    321             # - Consume via `__aiter__` and not `streaming_content` directly,
    322             #   to allow mapping of a sync iterator.
    323             # - Use aclosing() when consuming aiter. See
    324             #   https://github.com/python/cpython/commit/6e8dcdaaa49d4313bf9fab9f9923ca5828fbb10e
    325             async with aclosing(aiter(response)) as content:
     321            # Use aclosing() when consuming aiter. See
     322            # https://github.com/python/cpython/commit/6e8dcdaaa49d4313bf9fab9f9923ca5828fbb10e
     323            async with response as agen, aclosing(agen) as content:
    326324                async for part in content:
    327325                    for chunk, _ in self.chunk_bytes(part):
    328326                        await send(
  • django/http/__init__.py

    diff --git a/django/http/__init__.py b/django/http/__init__.py
    index 628564e..2df8fa6 100644
    a b from django.http.request import (  
    88)
    99from django.http.response import (
    1010    BadHeaderError,
     11
    1112    FileResponse,
    1213    Http404,
    1314    HttpResponse,
    __all__ = [  
    4748    "HttpResponseServerError",
    4849    "Http404",
    4950    "BadHeaderError",
     51
    5052    "JsonResponse",
    5153    "FileResponse",
    5254]
  • django/http/response.py

    diff --git a/django/http/response.py b/django/http/response.py
    index 9bf0b14..c1c0c20 100644
    a b import re  
    77import sys
    88import time
    99import warnings
     10
    1011from email.header import Header
    1112from http.client import responses
    1213from urllib.parse import urlsplit
    class BadHeaderError(ValueError):  
    104105    pass
    105106
    106107
     108
     109
     110
     111
    107112class HttpResponseBase:
    108113    """
    109114    An HTTP response base class with dictionary-accessed headers.
    class StreamingHttpResponse(HttpResponseBase):  
    479484
    480485    @property
    481486    def streaming_content(self):
     487
     488
     489
     490
     491
     492
     493
     494
     495
     496
     497
     498
     499
     500
     501
     502
     503
     504
    482505        if self.is_async:
    483506            # pull to lexical scope to capture fixed reference in case
    484507            # streaming_content is set again later.
    485508            _iterator = self._iterator
    486509
    487510            async def awrapper():
    488                 async for part in _iterator:
    489                     yield self.make_bytes(part)
     511                try:
     512                    async for part in _iterator:
     513                        yield self.make_bytes(part)
     514                finally:
     515                    if hasattr(_iterator, "aclose"):
     516                        await _iterator.aclose()
    490517
    491518            return awrapper()
    492519        else:
    class StreamingHttpResponse(HttpResponseBase):  
    498525
    499526    def _set_streaming_content(self, value):
    500527        # Ensure we can never iterate on "value" more than once.
     528
     529
     530
     531
     532
     533
    501534        try:
    502535            self._iterator = iter(value)
    503536            self.is_async = False
    class StreamingHttpResponse(HttpResponseBase):  
    507540        if hasattr(value, "close"):
    508541            self._resource_closers.append(value.close)
    509542
     543
     544
     545
     546
     547
     548
     549
     550
     551
     552
    510553    def __iter__(self):
     554
     555
     556
     557
     558
    511559        try:
    512560            return iter(self.streaming_content)
    513561        except TypeError:
    class StreamingHttpResponse(HttpResponseBase):  
    528576            return map(self.make_bytes, iter(async_to_sync(to_list)(self._iterator)))
    529577
    530578    async def __aiter__(self):
     579
     580
     581
     582
     583
    531584        try:
    532585            async for part in self.streaming_content:
    533586                yield part
    class StreamingHttpResponse(HttpResponseBase):  
    544597                yield part
    545598
    546599    def getvalue(self):
     600
     601
     602
     603
     604
    547605        return b"".join(self.streaming_content)
    548606
    549607
  • django/middleware/gzip.py

    diff --git a/django/middleware/gzip.py b/django/middleware/gzip.py
    index eb151d7..78b5739 100644
    a b  
     1
     2
    13from django.utils.cache import patch_vary_headers
    24from django.utils.deprecation import MiddlewareMixin
    35from django.utils.regex_helper import _lazy_re_compile
    class GZipMiddleware(MiddlewareMixin):  
    3133            return response
    3234
    3335        if response.streaming:
    34             if response.is_async:
     36            if response.is_acmgr:
     37                original_acmgr = response.streaming_content
     38                max_random_bytes = self.max_random_bytes
     39
     40                @asynccontextmanager
     41                async def compressed_acmgr():
     42                    async with original_acmgr as agen:
     43                        yield acompress_sequence(
     44                            agen,
     45                            max_random_bytes=max_random_bytes,
     46                        )
     47
     48                response.streaming_content = compressed_acmgr()
     49            elif response.is_async:
    3550                response.streaming_content = acompress_sequence(
    3651                    response.streaming_content,
    3752                    max_random_bytes=self.max_random_bytes,
  • django/utils/text.py

    diff --git a/django/utils/text.py b/django/utils/text.py
    index d1306f9..55bd6f5 100644
    a b def compress_sequence(sequence, *, max_random_bytes=None):  
    390390
    391391
    392392async def acompress_sequence(sequence, *, max_random_bytes=None):
    393     buf = StreamingBuffer()
    394     filename = _get_random_filename(max_random_bytes) if max_random_bytes else None
    395     with GzipFile(
    396         filename=filename, mode="wb", compresslevel=6, fileobj=buf, mtime=0
    397     ) as zfile:
    398         # Output headers...
     393    try:
     394        buf = StreamingBuffer()
     395        filename = _get_random_filename(max_random_bytes) if max_random_bytes else None
     396        with GzipFile(
     397            filename=filename, mode="wb", compresslevel=6, fileobj=buf, mtime=0
     398        ) as zfile:
     399            # Output headers...
     400            yield buf.read()
     401            async for item in sequence:
     402                zfile.write(item)
     403                zfile.flush()
     404                data = buf.read()
     405                if data:
     406                    yield data
    399407        yield buf.read()
    400         async for item in sequence:
    401             zfile.write(item)
    402             zfile.flush()
    403             data = buf.read()
    404             if data:
    405                 yield data
    406     yield buf.read()
     408    finally:
     409        if hasattr(sequence, "aclose"):
     410            await sequence.aclose()
    407411
    408412
    409413# Expression to match some_token and some_token="with spaces" (and similarly
  • docs/ref/request-response.txt

    diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt
    index ba60415..945683a 100644
    a b is streaming. If you perform long-running operations in your view before  
    14241424returning the ``StreamingHttpResponse`` object, then you may also want to
    14251425:ref:`handle disconnections in the view <async-handling-disconnect>` itself.
    14261426
     1427
     1428
     1429
     1430
     1431
     1432
     1433
     1434
     1435
     1436
     1437
     1438
     1439
     1440
     1441
     1442
     1443
     1444
     1445
     1446
     1447
     1448
     1449
     1450
     1451
     1452
     1453
     1454
     1455
     1456
     1457
     1458
     1459
     1460
     1461
     1462
     1463
     1464
     1465
     1466
     1467
     1468
     1469
     1470
     1471
     1472
     1473
     1474
     1475
     1476
     1477
     1478
     1479
     1480
     1481
     1482
     1483
     1484
     1485
     1486
     1487
     1488
     1489
     1490
     1491
     1492
     1493
     1494
     1495
     1496
     1497
     1498
     1499
     1500
     1501
     1502
     1503
     1504
     1505
     1506
     1507
     1508
     1509
     1510
     1511
     1512
     1513
     1514
     1515
     1516
     1517
     1518
     1519
     1520
     1521
     1522
     1523
    14271524``FileResponse`` objects
    14281525========================
    14291526
  • docs/releases/6.1.txt

    diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
    index 00eaf53..790782c 100644
    a b Pagination  
    323323Requests and Responses
    324324~~~~~~~~~~~~~~~~~~~~~~
    325325
     326
     327
     328
     329
     330
    326331* :attr:`HttpRequest.multipart_parser_class <django.http.HttpRequest.multipart_parser_class>`
    327332  can now be customized to use a different multipart parser class.
    328333
  • tests/asgi/urls.py

    diff --git a/tests/asgi/urls.py b/tests/asgi/urls.py
    index 0311cf3..b7fa206 100644
    a b  
    11import asyncio
     2
    23import threading
    34import time
    45
  • tests/httpwrappers/tests.py

    diff --git a/tests/httpwrappers/tests.py b/tests/httpwrappers/tests.py
    index 3e8364e..a2add61 100644
    a b  
     1
     2
    13import copy
    24import json
    35import os
    46import pickle
     7
    58import unittest
    69import uuid
    710
    from django.http import (  
    1619    HttpResponseNotModified,
    1720    HttpResponsePermanentRedirect,
    1821    HttpResponseRedirect,
     22
    1923    JsonResponse,
    2024    QueryDict,
    2125    SimpleCookie,
    class StreamingHttpResponseTests(SimpleTestCase):  
    808812        with self.assertWarnsMessage(Warning, msg):
    809813            self.assertEqual(b"hello", await anext(aiter(r)))
    810814
     815
     816
     817
     818
     819
     820
     821
     822
     823
     824
     825
     826
     827
     828
     829
     830
     831
     832
     833
     834
     835
     836
     837
     838
     839
     840
     841
     842
     843
     844
     845
     846
     847
     848
     849
     850
     851
     852
     853
     854
     855
     856
     857
     858
     859
     860
     861
     862
     863
     864
     865
     866
     867
     868
     869
     870
     871
     872
     873
     874
     875
     876
     877
     878
     879
     880
     881
     882
     883
     884
     885
     886
     887
     888
     889
     890
     891
     892
     893
     894
     895
     896
     897
     898
     899
     900
     901
     902
     903
     904
     905
     906
     907
     908
     909
     910
     911
     912
     913
     914
     915
     916
     917
     918
     919
     920
     921
     922
     923
     924
     925
     926
     927
     928
     929
     930
     931
     932
     933
     934
     935
     936
     937
     938
     939
     940
     941
     942
     943
     944
     945
     946
     947
     948
     949
     950
     951
     952
     953
     954
     955
     956
     957
     958
     959
     960
     961
     962
     963
     964
     965
     966
     967
     968
     969
     970
     971
     972
     973
     974
     975
     976
     977
     978
     979
     980
     981
     982
     983
     984
     985
     986
     987
     988
     989
     990
     991
     992
     993
     994
     995
     996
     997
     998
     999
     1000
     1001
     1002
     1003
     1004
     1005
     1006
     1007
     1008
     1009
     1010
     1011
     1012
     1013
     1014
     1015
     1016
     1017
     1018
     1019
     1020
     1021
     1022
     1023
     1024
     1025
     1026
     1027
     1028
     1029
     1030
     1031
     1032
     1033
     1034
     1035
     1036
     1037
     1038
     1039
     1040
     1041
     1042
     1043
     1044
     1045
     1046
     1047
     1048
     1049
     1050
     1051
     1052
     1053
     1054
     1055
     1056
     1057
     1058
    8111059    def test_text_attribute_error(self):
    8121060        r = StreamingHttpResponse(iter(["hello", "world"]))
    8131061        msg = "This %s instance has no `text` attribute." % r.__class__.__name__
  • tests/middleware/tests.py

    diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py
    index a61c4b1..4192ada 100644
    a b  
     1
    12import gzip
    23import random
    34import re
    class GZipMiddlewareTest(SimpleTestCase):  
    936937        self.assertEqual(r.get("Content-Encoding"), "gzip")
    937938        self.assertFalse(r.has_header("Content-Length"))
    938939
     940
     941
     942
     943
     944
     945
     946
     947
     948
     949
     950
     951
     952
     953
     954
     955
     956
     957
     958
     959
     960
     961
     962
     963
     964
     965
     966
     967
     968
     969
     970
    939971    def test_compress_streaming_response_unicode(self):
    940972        """
    941973        Compression is performed on responses with streaming Unicode content.

comment:10 by Jacob Walls, 2 months ago

Needs documentation: unset
Owner: changed from Thomas Grainger to Carlton Gibson
Note: See TracTickets for help on using tickets.
Back to Top