Skip to content

Commit ae4e2ef

Browse files
authored
feat: add streaming assertions (#1)
1 parent 251053b commit ae4e2ef

6 files changed

Lines changed: 386 additions & 6 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
license = { text = "Apache-2.0" }
77
requires-python = ">=3.11"
88
authors = [
9-
{ name = "Unay Santisteban", email = "usantisteban@outlook.com" }
9+
{ name = "Unay Santisteban", email = "usantisteban@othercode.io" }
1010
]
1111
keywords = ["django", "testing", "assertions", "fluent", "pytest"]
1212
classifiers = [
@@ -15,7 +15,6 @@ classifiers = [
1515
"License :: OSI Approved :: Apache Software License",
1616
"Operating System :: OS Independent",
1717
"Programming Language :: Python :: 3",
18-
1918
"Programming Language :: Python :: 3.11",
2019
"Programming Language :: Python :: 3.12",
2120
"Programming Language :: Python :: 3.13",
@@ -33,9 +32,9 @@ dependencies = [
3332
]
3433

3534
[project.urls]
36-
Homepage = "https://github.com/usantisteban/pyssertive"
37-
Repository = "https://github.com/usantisteban/pyssertive.git"
38-
Issues = "https://github.com/usantisteban/pyssertive/issues"
35+
Homepage = "https://github.com/othercodes/pyssertive"
36+
Repository = "https://github.com/othercodes/pyssertive.git"
37+
Issues = "https://github.com/othercodes/pyssertive/issues"
3938

4039
[build-system]
4140
requires = ["setuptools>=70.0.0"]
@@ -77,7 +76,6 @@ addopts = "--cov=pyssertive --cov-report=term-missing --cov-fail-under=100"
7776
[tool.coverage.run]
7877
branch = true
7978
source = ["src/pyssertive"]
80-
omit = ["src/pyssertive/db.py"]
8179

8280
[tool.coverage.report]
8381
exclude_lines = [

src/pyssertive/http/assertions.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,105 @@ def assert_cookie_expired(self, name: str) -> Self:
315315
is_expired = max_age == 0 or max_age == "0" or cookie.value == ""
316316
assert is_expired, f"Cookie '{name}' is not expired (max-age={max_age}, value='{cookie.value}')"
317317
return self # type: ignore[return-value]
318+
319+
320+
class StreamingAssertionsMixin:
321+
_response: HttpResponse
322+
323+
def _get_streaming_content(self) -> str:
324+
# Streaming content can only be consumed once, so cache it for reuse
325+
from django.http import StreamingHttpResponse
326+
327+
if hasattr(self, "_cached_streaming_content"):
328+
return self._cached_streaming_content # type: ignore[return-value]
329+
330+
if isinstance(self._response, StreamingHttpResponse):
331+
content = b"".join(self._response.streaming_content).decode("utf-8")
332+
else:
333+
content = self._response.content.decode("utf-8")
334+
335+
self._cached_streaming_content = content # type: ignore[attr-defined]
336+
return content
337+
338+
def _get_streaming_lines(self, strip_empty: bool = True) -> list[str]:
339+
# Handles both \n and \r\n line endings
340+
content = self._get_streaming_content()
341+
content = content.replace("\r\n", "\n")
342+
lines = content.split("\n")
343+
if strip_empty:
344+
lines = [line for line in lines if line.strip()]
345+
return lines
346+
347+
def assert_streaming(self) -> Self:
348+
from django.http import StreamingHttpResponse
349+
350+
assert isinstance(self._response, StreamingHttpResponse), (
351+
f"Expected StreamingHttpResponse, got {type(self._response).__name__}"
352+
)
353+
return self # type: ignore[return-value]
354+
355+
def assert_download(self, filename: str | None = None) -> Self:
356+
# If filename provided, also asserts the filename matches.
357+
disposition = self._response.get("Content-Disposition", "")
358+
assert "attachment" in disposition, f"Expected Content-Disposition: attachment header, got '{disposition}'"
359+
if filename is not None:
360+
assert f'filename="{filename}"' in disposition or f"filename={filename}" in disposition, (
361+
f"Expected filename '{filename}' in Content-Disposition, got '{disposition}'"
362+
)
363+
return self # type: ignore[return-value]
364+
365+
def assert_streaming_contains(self, text: str) -> Self:
366+
content = self._get_streaming_content()
367+
assert text in content, f"Expected streaming content to contain '{text}'"
368+
return self # type: ignore[return-value]
369+
370+
def assert_streaming_not_contains(self, text: str) -> Self:
371+
content = self._get_streaming_content()
372+
assert text not in content, f"Expected streaming content to NOT contain '{text}'"
373+
return self # type: ignore[return-value]
374+
375+
def assert_streaming_matches(self, pattern: str) -> Self:
376+
content = self._get_streaming_content()
377+
assert re.search(pattern, content), f"Expected streaming content to match pattern '{pattern}'"
378+
return self # type: ignore[return-value]
379+
380+
def assert_streaming_line_count(
381+
self, exact: int | None = None, *, min: int | None = None, max: int | None = None
382+
) -> Self:
383+
# Counts non-empty lines only
384+
lines = self._get_streaming_lines()
385+
count = len(lines)
386+
387+
if exact is not None:
388+
assert count == exact, f"Expected exactly {exact} lines, got {count}"
389+
if min is not None:
390+
assert count >= min, f"Expected at least {min} lines, got {count}"
391+
if max is not None:
392+
assert count <= max, f"Expected at most {max} lines, got {count}"
393+
394+
return self # type: ignore[return-value]
395+
396+
def assert_streaming_empty(self) -> Self:
397+
# Checks for no non-empty lines
398+
lines = self._get_streaming_lines()
399+
assert len(lines) == 0, f"Expected empty streaming content, got {len(lines)} lines"
400+
return self # type: ignore[return-value]
401+
402+
def assert_streaming_csv_header(self, columns: list[str] | str) -> Self:
403+
# columns: list of names or comma-separated string
404+
lines = self._get_streaming_lines(strip_empty=False)
405+
has_content = len(lines) > 0 and lines[0].strip()
406+
assert has_content, "Expected CSV content but response is empty"
407+
408+
header = lines[0]
409+
expected = ",".join(columns) if isinstance(columns, list) else columns
410+
411+
assert header == expected, f"Expected CSV header '{expected}', got '{header}'"
412+
return self # type: ignore[return-value] # type: ignore[return-value]
413+
414+
def assert_streaming_line(self, index: int, expected: str) -> Self:
415+
lines = self._get_streaming_lines()
416+
assert index < len(lines), f"Line index {index} out of range (only {len(lines)} lines)"
417+
actual = lines[index]
418+
assert actual == expected, f"Line {index}: expected '{expected}', got '{actual}'"
419+
return self # type: ignore[return-value]

src/pyssertive/http/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
HTMLContentAssertionsMixin,
1212
HttpStatusAssertionsMixin,
1313
JsonContentAssertionsMixin,
14+
StreamingAssertionsMixin,
1415
)
1516
from pyssertive.http.debug import DebugResponseMixin
1617
from pyssertive.http.django import (
@@ -24,6 +25,7 @@ class FluentResponse(
2425
DebugResponseMixin,
2526
SessionAssertionsMixin,
2627
CookieAssertionsMixin,
28+
StreamingAssertionsMixin,
2729
TemplateContextAssertionsMixin,
2830
FormValidationAssertionsMixin,
2931
HTMLContentAssertionsMixin,

tests/app/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,10 @@
4646
path("cookie-set/", views.cookie_set_view),
4747
path("cookie-expire/", views.cookie_expire_view),
4848
path("cookie-detailed/", views.cookie_detailed_view),
49+
# Streaming and download endpoints
50+
path("streaming-csv/", views.streaming_csv_view),
51+
path("streaming-text/", views.streaming_text_view),
52+
path("streaming-empty/", views.streaming_empty_view),
53+
path("download/", views.download_view),
54+
path("inline/", views.inline_view),
4955
]

tests/app/views.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,47 @@ def cookie_detailed_view(request: HttpRequest) -> HttpResponse:
209209
response.set_cookie("detailed", "value", max_age=3600, path="/api/")
210210
response.set_cookie("maxage_only", "value", max_age=7200)
211211
return response
212+
213+
214+
def streaming_csv_view(request: HttpRequest) -> HttpResponse:
215+
from django.http import StreamingHttpResponse
216+
217+
def generate_csv():
218+
yield "Name,Email,Age\r\n"
219+
yield "John,john@example.com,30\r\n"
220+
yield "Jane,jane@example.com,25\r\n"
221+
yield "Bob,bob@example.com,35\r\n"
222+
223+
response = StreamingHttpResponse(generate_csv(), content_type="text/csv")
224+
response["Content-Disposition"] = 'attachment; filename="users.csv"'
225+
return response
226+
227+
228+
def streaming_text_view(request: HttpRequest) -> HttpResponse:
229+
from django.http import StreamingHttpResponse
230+
231+
def generate_text():
232+
yield "Line 1\n"
233+
yield "Line 2\n"
234+
yield "Line 3\n"
235+
236+
return StreamingHttpResponse(generate_text(), content_type="text/plain")
237+
238+
239+
def streaming_empty_view(request: HttpRequest) -> HttpResponse:
240+
from django.http import StreamingHttpResponse
241+
242+
def generate_empty():
243+
yield ""
244+
245+
return StreamingHttpResponse(generate_empty(), content_type="text/plain")
246+
247+
248+
def download_view(request: HttpRequest) -> HttpResponse:
249+
response = HttpResponse("file content here", content_type="application/octet-stream")
250+
response["Content-Disposition"] = 'attachment; filename="report.txt"'
251+
return response
252+
253+
254+
def inline_view(request: HttpRequest) -> HttpResponse:
255+
return HttpResponse("inline content", content_type="text/plain")

0 commit comments

Comments
 (0)