Skip to content

Commit 3e5443a

Browse files
ref: replace CsvMixin with a typesafe alternative (#74967)
mypy 1.11 points out that the approach here is unsafe CsvMixin is used in getsentry so I'll follow up with deleting it after converting the getsentry usages over as well <!-- Describe your PR here. -->
1 parent d98f198 commit 3e5443a

File tree

4 files changed

+63
-13
lines changed

4 files changed

+63
-13
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,7 @@ module = [
617617
"sentry.utils.urls",
618618
"sentry.utils.uwsgi",
619619
"sentry.utils.zip",
620+
"sentry.web.frontend.csv",
620621
"sentry_plugins.base",
621622
"tests.sentry.api.endpoints.issues.*",
622623
"tests.sentry.event_manager.test_event_manager",

src/sentry/web/frontend/csv.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
import csv
4+
from collections.abc import Generator, Iterable
5+
from typing import Generic, TypeVar
6+
7+
from django.http import StreamingHttpResponse
8+
9+
T = TypeVar("T")
10+
11+
12+
# csv.writer doesn't provide a non-file interface
13+
# https://docs.djangoproject.com/en/1.9/howto/outputting-csv/#streaming-large-csv-files
14+
class Echo:
15+
def write(self, value: str) -> str:
16+
return value
17+
18+
19+
class CsvResponder(Generic[T]):
20+
def get_header(self) -> tuple[str, ...]:
21+
raise NotImplementedError
22+
23+
def get_row(self, item: T) -> tuple[str, ...]:
24+
raise NotImplementedError
25+
26+
def respond(self, iterable: Iterable[T], filename: str) -> StreamingHttpResponse:
27+
def row_iter() -> Generator[tuple[str, ...], None, None]:
28+
header = self.get_header()
29+
if header:
30+
yield header
31+
for item in iterable:
32+
yield self.get_row(item)
33+
34+
pseudo_buffer = Echo()
35+
writer = csv.writer(pseudo_buffer)
36+
return StreamingHttpResponse(
37+
(writer.writerow(r) for r in row_iter()),
38+
content_type="text/csv",
39+
headers={"Content-Disposition": f'attachment; filename="{filename}.csv"'},
40+
)
Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,36 @@
1+
from __future__ import annotations
2+
13
from django.http import Http404
4+
from django.http.response import HttpResponseBase
25
from rest_framework.request import Request
3-
from rest_framework.response import Response
46

57
from sentry.api.base import EnvironmentMixin
68
from sentry.data_export.base import ExportError
79
from sentry.data_export.processors.issues_by_tag import IssuesByTagProcessor
810
from sentry.models.environment import Environment
11+
from sentry.tagstore.types import GroupTagValue
912
from sentry.web.frontend.base import ProjectView, region_silo_view
10-
from sentry.web.frontend.mixins.csv import CsvMixin
13+
from sentry.web.frontend.csv import CsvResponder
1114

1215

13-
@region_silo_view
14-
class GroupTagExportView(ProjectView, CsvMixin, EnvironmentMixin):
15-
required_scope = "event:read"
16+
class GroupTagCsvResponder(CsvResponder[GroupTagValue]):
17+
def __init__(self, key: str) -> None:
18+
self.key = key
1619

17-
def get_header(self, key):
18-
return tuple(IssuesByTagProcessor.get_header_fields(key))
20+
def get_header(self) -> tuple[str, ...]:
21+
return tuple(IssuesByTagProcessor.get_header_fields(self.key))
1922

20-
def get_row(self, item, key):
21-
fields = IssuesByTagProcessor.get_header_fields(key)
22-
item_dict = IssuesByTagProcessor.serialize_row(item, key)
23-
return (item_dict[field] for field in fields)
23+
def get_row(self, item: GroupTagValue) -> tuple[str, ...]:
24+
fields = IssuesByTagProcessor.get_header_fields(self.key)
25+
item_dict = IssuesByTagProcessor.serialize_row(item, self.key)
26+
return tuple(item_dict[field] for field in fields)
2427

25-
def get(self, request: Request, organization, project, group_id, key) -> Response:
2628

29+
@region_silo_view
30+
class GroupTagExportView(ProjectView, EnvironmentMixin):
31+
required_scope = "event:read"
32+
33+
def get(self, request: Request, organization, project, group_id, key) -> HttpResponseBase:
2734
# If the environment doesn't exist then the tag can't possibly exist
2835
try:
2936
environment_id = self._get_environment_id_from_request(request, project.organization_id)
@@ -43,4 +50,4 @@ def get(self, request: Request, organization, project, group_id, key) -> Respons
4350

4451
filename = f"{processor.group.qualified_short_id or processor.group.id}-{key}"
4552

46-
return self.to_csv_response(processor.get_raw_data(), filename, key=key)
53+
return GroupTagCsvResponder(key).respond(processor.get_raw_data(), filename)

src/sentry/web/frontend/mixins/csv.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ def write(self, value):
1111

1212

1313
class CsvMixin:
14+
"""deprecated: will be removed! use sentry.web.csv.CsvResponder instead!"""
15+
1416
def get_header(self, **kwargs):
1517
return ()
1618

0 commit comments

Comments
 (0)