Skip to content

Commit 0ce9021

Browse files
authored
Fix parsing of Django path patterns (#2452)
Parse Django 2.0+ `path` patterns directly without turning them into regexes first.
1 parent c1d157d commit 0ce9021

File tree

2 files changed

+95
-32
lines changed

2 files changed

+95
-32
lines changed

sentry_sdk/integrations/django/transactions.py

+31-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""
2-
Copied from raven-python. Used for
3-
`DjangoIntegration(transaction_fron="raven_legacy")`.
2+
Copied from raven-python.
3+
4+
Despite being called "legacy" in some places this resolver is very much still
5+
in use.
46
"""
57

68
from __future__ import absolute_import
@@ -19,6 +21,13 @@
1921
from typing import Union
2022
from re import Pattern
2123

24+
from django import VERSION as DJANGO_VERSION
25+
26+
if DJANGO_VERSION >= (2, 0):
27+
from django.urls.resolvers import RoutePattern
28+
else:
29+
RoutePattern = None
30+
2231
try:
2332
from django.urls import get_resolver
2433
except ImportError:
@@ -36,6 +45,9 @@ def get_regex(resolver_or_pattern):
3645

3746

3847
class RavenResolver(object):
48+
_new_style_group_matcher = re.compile(
49+
r"<(?:([^>:]+):)?([^>]+)>"
50+
) # https://github.com/django/django/blob/21382e2743d06efbf5623e7c9b6dccf2a325669b/django/urls/resolvers.py#L245-L247
3951
_optional_group_matcher = re.compile(r"\(\?\:([^\)]+)\)")
4052
_named_group_matcher = re.compile(r"\(\?P<(\w+)>[^\)]+\)+")
4153
_non_named_group_matcher = re.compile(r"\([^\)]+\)")
@@ -46,7 +58,7 @@ class RavenResolver(object):
4658
_cache = {} # type: Dict[URLPattern, str]
4759

4860
def _simplify(self, pattern):
49-
# type: (str) -> str
61+
# type: (Union[URLPattern, URLResolver]) -> str
5062
r"""
5163
Clean up urlpattern regexes into something readable by humans:
5264
@@ -56,11 +68,24 @@ def _simplify(self, pattern):
5668
To:
5769
> "{sport_slug}/athletes/{athlete_slug}/"
5870
"""
71+
# "new-style" path patterns can be parsed directly without turning them
72+
# into regexes first
73+
if (
74+
RoutePattern is not None
75+
and hasattr(pattern, "pattern")
76+
and isinstance(pattern.pattern, RoutePattern)
77+
):
78+
return self._new_style_group_matcher.sub(
79+
lambda m: "{%s}" % m.group(2), pattern.pattern._route
80+
)
81+
82+
result = get_regex(pattern).pattern
83+
5984
# remove optional params
6085
# TODO(dcramer): it'd be nice to change these into [%s] but it currently
6186
# conflicts with the other rules because we're doing regexp matches
6287
# rather than parsing tokens
63-
result = self._optional_group_matcher.sub(lambda m: "%s" % m.group(1), pattern)
88+
result = self._optional_group_matcher.sub(lambda m: "%s" % m.group(1), result)
6489

6590
# handle named groups first
6691
result = self._named_group_matcher.sub(lambda m: "{%s}" % m.group(1), result)
@@ -113,8 +138,8 @@ def _resolve(self, resolver, path, parents=None):
113138
except KeyError:
114139
pass
115140

116-
prefix = "".join(self._simplify(get_regex(p).pattern) for p in parents)
117-
result = prefix + self._simplify(get_regex(pattern).pattern)
141+
prefix = "".join(self._simplify(p) for p in parents)
142+
result = prefix + self._simplify(pattern)
118143
if not result.startswith("/"):
119144
result = "/" + result
120145
self._cache[pattern] = result

tests/integrations/django/test_transactions.py

+64-26
Original file line numberDiff line numberDiff line change
@@ -3,83 +3,121 @@
33
import pytest
44
import django
55

6+
try:
7+
from unittest import mock # python 3.3 and above
8+
except ImportError:
9+
import mock # python < 3.3
10+
11+
12+
# django<2.0 has only `url` with regex based patterns.
13+
# django>=2.0 renames `url` to `re_path`, and additionally introduces `path`
14+
# for new style URL patterns, e.g. <int:article_id>.
615
if django.VERSION >= (2, 0):
7-
# TODO: once we stop supporting django < 2, use the real name of this
8-
# function (re_path)
9-
from django.urls import re_path as url
16+
from django.urls import path, re_path
17+
from django.urls.converters import PathConverter
1018
from django.conf.urls import include
1119
else:
12-
from django.conf.urls import url, include
20+
from django.conf.urls import url as re_path, include
1321

1422
if django.VERSION < (1, 9):
15-
included_url_conf = (url(r"^foo/bar/(?P<param>[\w]+)", lambda x: ""),), "", ""
23+
included_url_conf = (re_path(r"^foo/bar/(?P<param>[\w]+)", lambda x: ""),), "", ""
1624
else:
17-
included_url_conf = ((url(r"^foo/bar/(?P<param>[\w]+)", lambda x: ""),), "")
25+
included_url_conf = ((re_path(r"^foo/bar/(?P<param>[\w]+)", lambda x: ""),), "")
1826

1927
from sentry_sdk.integrations.django.transactions import RavenResolver
2028

2129

2230
example_url_conf = (
23-
url(r"^api/(?P<project_id>[\w_-]+)/store/$", lambda x: ""),
24-
url(r"^api/(?P<version>(v1|v2))/author/$", lambda x: ""),
25-
url(
31+
re_path(r"^api/(?P<project_id>[\w_-]+)/store/$", lambda x: ""),
32+
re_path(r"^api/(?P<version>(v1|v2))/author/$", lambda x: ""),
33+
re_path(
2634
r"^api/(?P<project_id>[^\/]+)/product/(?P<pid>(?:\d+|[A-Fa-f0-9-]{32,36}))/$",
2735
lambda x: "",
2836
),
29-
url(r"^report/", lambda x: ""),
30-
url(r"^example/", include(included_url_conf)),
37+
re_path(r"^report/", lambda x: ""),
38+
re_path(r"^example/", include(included_url_conf)),
3139
)
3240

3341

34-
def test_legacy_resolver_no_match():
42+
def test_resolver_no_match():
3543
resolver = RavenResolver()
3644
result = resolver.resolve("/foo/bar", example_url_conf)
3745
assert result is None
3846

3947

40-
def test_legacy_resolver_complex_match():
48+
def test_resolver_re_path_complex_match():
4149
resolver = RavenResolver()
4250
result = resolver.resolve("/api/1234/store/", example_url_conf)
4351
assert result == "/api/{project_id}/store/"
4452

4553

46-
def test_legacy_resolver_complex_either_match():
54+
def test_resolver_re_path_complex_either_match():
4755
resolver = RavenResolver()
4856
result = resolver.resolve("/api/v1/author/", example_url_conf)
4957
assert result == "/api/{version}/author/"
5058
result = resolver.resolve("/api/v2/author/", example_url_conf)
5159
assert result == "/api/{version}/author/"
5260

5361

54-
def test_legacy_resolver_included_match():
62+
def test_resolver_re_path_included_match():
5563
resolver = RavenResolver()
5664
result = resolver.resolve("/example/foo/bar/baz", example_url_conf)
5765
assert result == "/example/foo/bar/{param}"
5866

5967

60-
def test_capture_multiple_named_groups():
68+
def test_resolver_re_path_multiple_groups():
6169
resolver = RavenResolver()
6270
result = resolver.resolve(
6371
"/api/myproject/product/cb4ef1caf3554c34ae134f3c1b3d605f/", example_url_conf
6472
)
6573
assert result == "/api/{project_id}/product/{pid}/"
6674

6775

68-
@pytest.mark.skipif(django.VERSION < (2, 0), reason="Requires Django > 2.0")
69-
def test_legacy_resolver_newstyle_django20_urlconf():
70-
from django.urls import path
71-
76+
@pytest.mark.skipif(
77+
django.VERSION < (2, 0),
78+
reason="Django>=2.0 required for <converter:parameter> patterns",
79+
)
80+
def test_resolver_path_group():
7281
url_conf = (path("api/v2/<int:project_id>/store/", lambda x: ""),)
7382
resolver = RavenResolver()
7483
result = resolver.resolve("/api/v2/1234/store/", url_conf)
7584
assert result == "/api/v2/{project_id}/store/"
7685

7786

78-
@pytest.mark.skipif(django.VERSION < (2, 0), reason="Requires Django > 2.0")
79-
def test_legacy_resolver_newstyle_django20_urlconf_multiple_groups():
80-
from django.urls import path
81-
82-
url_conf = (path("api/v2/<int:project_id>/product/<int:pid>", lambda x: ""),)
87+
@pytest.mark.skipif(
88+
django.VERSION < (2, 0),
89+
reason="Django>=2.0 required for <converter:parameter> patterns",
90+
)
91+
def test_resolver_path_multiple_groups():
92+
url_conf = (path("api/v2/<str:project_id>/product/<int:pid>", lambda x: ""),)
8393
resolver = RavenResolver()
84-
result = resolver.resolve("/api/v2/1234/product/5689", url_conf)
94+
result = resolver.resolve("/api/v2/myproject/product/5689", url_conf)
8595
assert result == "/api/v2/{project_id}/product/{pid}"
96+
97+
98+
@pytest.mark.skipif(
99+
django.VERSION < (2, 0),
100+
reason="Django>=2.0 required for <converter:parameter> patterns",
101+
)
102+
def test_resolver_path_complex_path():
103+
class CustomPathConverter(PathConverter):
104+
regex = r"[^/]+(/[^/]+){0,2}"
105+
106+
with mock.patch(
107+
"django.urls.resolvers.get_converter", return_value=CustomPathConverter
108+
):
109+
url_conf = (path("api/v3/<custom_path:my_path>", lambda x: ""),)
110+
resolver = RavenResolver()
111+
result = resolver.resolve("/api/v3/abc/def/ghi", url_conf)
112+
assert result == "/api/v3/{my_path}"
113+
114+
115+
@pytest.mark.skipif(
116+
django.VERSION < (2, 0),
117+
reason="Django>=2.0 required for <converter:parameter> patterns",
118+
)
119+
def test_resolver_path_no_converter():
120+
url_conf = (path("api/v4/<project_id>", lambda x: ""),)
121+
resolver = RavenResolver()
122+
result = resolver.resolve("/api/v4/myproject", url_conf)
123+
assert result == "/api/v4/{project_id}"

0 commit comments

Comments
 (0)