Skip to content

Commit

Permalink
ref: add mypy plugin for _csp_replace response attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
asottile-sentry committed Feb 24, 2025
1 parent 5dd543a commit fdb6490
Show file tree
Hide file tree
Showing 5 changed files with 48 additions and 3 deletions.
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ module = [
"sentry.integrations.gitlab.issues",
"sentry.integrations.jira.client",
"sentry.integrations.jira.integration",
"sentry.integrations.jira.views.base",
"sentry.integrations.jira.webhooks.base",
"sentry.integrations.jira.webhooks.issue_updated",
"sentry.integrations.jira_server.integration",
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/integrations/jira/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class JiraSentryUIBaseView(View):
Base class for the UI of the Sentry integration in Jira.
"""

html_file: str # abstract

def get_response(self, context):
"""
Wrap the HTML rendered using the template at `self.html_file` in a Response and
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/toolbar/views/iframe_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def _respond_with_state(self, state: str):
referrer = _get_referrer(self.request) or ""

# This is an alternative to @csp_replace - we need to use this pattern to access the referrer.
response._csp_replace = {"frame-ancestors": [referrer.strip("/") or "'none'"]} # type: ignore[attr-defined]
response._csp_replace = {"frame-ancestors": [referrer.strip("/") or "'none'"]}
response["X-Frame-Options"] = "DENY" if referrer == "" else "ALLOWALL"

return response
19 changes: 19 additions & 0 deletions tests/tools/mypy_helpers/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,25 @@ def test_adjusted_drf_request_auth() -> None:
assert out == expected_plugins


def test_csp_response_attribute() -> None:
# technically undocumented -- django-csp's decorators usually do this
src = """\
from django.http import HttpResponse
x: HttpResponse
x._csp_replace = {"inline-src": ["self"]}
"""
expected = """\
<string>:3: error: "HttpResponse" has no attribute "_csp_replace" [attr-defined]
Found 1 error in 1 file (checked 1 source file)
"""
ret, out = call_mypy(src, plugins=[])
assert ret == 1
assert out == expected

ret, out = call_mypy(src)
assert ret == 0, (ret, out)


def test_lazy_service_wrapper() -> None:
src = """\
from typing import assert_type, Literal
Expand Down
27 changes: 26 additions & 1 deletion tools/mypy_helpers/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from mypy.build import PRI_MYPY
from mypy.errorcodes import ATTR_DEFINED
from mypy.messages import format_type
from mypy.nodes import ARG_POS, MypyFile, TypeInfo
from mypy.nodes import ARG_POS, MDEF, MypyFile, SymbolTableNode, TypeInfo, Var
from mypy.plugin import (
AttributeContext,
ClassDefContext,
Expand Down Expand Up @@ -127,6 +127,29 @@ def _adjust_request_members(ctx: ClassDefContext) -> None:
add_attribute_to_class(ctx.api, ctx.cls, "auth", _request_auth_tp(ctx.api))


def _add_name_to_info(ti: TypeInfo, name: str, tp: Type) -> None:
node = Var(name, tp)
node.info = ti
node._fullname = f"{ti.fullname}.{name}"

ti.names[name] = SymbolTableNode(MDEF, node, plugin_generated=True)


def _adjust_http_response_members(ctx: ClassDefContext) -> None:
# there isn't a good plugin point for HttpResponseBase so we add it here?
if ctx.cls.name == "HttpResponse":
dict_str_list_str = ctx.api.named_type(
"builtins.dict",
[
ctx.api.named_type("builtins.str"),
ctx.api.named_type("builtins.list", [ctx.api.named_type("builtins.str")]),
],
)
base = ctx.cls.info.bases[0].type
assert base.name == "HttpResponseBase", base.name
_add_name_to_info(base, "_csp_replace", dict_str_list_str)


def _lazy_service_wrapper_attribute(ctx: AttributeContext, *, attr: str) -> Type:
# we use `Any` as the `__getattr__` return value
# allow existing attributes to be returned as normal if they are not `Any`
Expand Down Expand Up @@ -162,6 +185,8 @@ def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None
return _adjust_http_request_members
elif fullname == "django.http.request.HttpRequest":
return _adjust_request_members
elif fullname == "django.http.response.HttpResponseBase":
return _adjust_http_response_members
else:
return None

Expand Down

0 comments on commit fdb6490

Please sign in to comment.