Skip to content

Commit a96bcba

Browse files
authored
Merge pull request #2 from kraken-tech/initial-import
Porting code from private implementation
2 parents 17b3a21 + 04d09f2 commit a96bcba

34 files changed

+4739
-3
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import types
2+
from typing import Self
3+
4+
import attrs
5+
from django.urls import resolvers
6+
7+
8+
@attrs.frozen
9+
class Where:
10+
"""
11+
Used to say where an error with a url pattern can be found
12+
"""
13+
14+
name: str
15+
regex: str
16+
module: str
17+
namespace: str
18+
19+
@classmethod
20+
def empty(cls) -> Self:
21+
return cls(name="", regex="", module="", namespace="")
22+
23+
@classmethod
24+
def from_resolver(
25+
cls,
26+
resolver: resolvers.URLResolver,
27+
*,
28+
regex: str,
29+
name: str | None = None,
30+
namespace: str | None = None,
31+
) -> Self:
32+
if isinstance(resolver.urlconf_module, types.ModuleType):
33+
return cls(
34+
module=resolver.urlconf_module.__file__ or "",
35+
name=name or "",
36+
namespace=namespace or "",
37+
regex=regex,
38+
)
39+
else:
40+
return cls(module="", name=name or "", namespace=namespace or "", regex=regex)
41+
42+
def display(self, *, indent: str = " ", display_regex: bool = True) -> str:
43+
parts: list[str] = []
44+
if self.module:
45+
parts.append(f"{indent}module = {self.module}")
46+
if self.name:
47+
parts.append(f"{indent}name = {self.name}")
48+
if self.namespace:
49+
parts.append(f"{indent}namespace = {self.namespace}")
50+
if self.regex and display_regex:
51+
parts.append(f"{indent}regex = {self.regex}")
52+
return "\n".join(parts)
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
from __future__ import annotations
2+
3+
import abc
4+
import typing
5+
from collections.abc import Iterator, Sequence
6+
from typing import Protocol, Self
7+
8+
import attrs
9+
10+
from . import _display, _functions, _raw_patterns
11+
12+
13+
class _Display(Protocol):
14+
def __call__(self, *, indent: str = ...) -> str: ...
15+
16+
17+
class InvalidPattern(Exception):
18+
pass
19+
20+
21+
@attrs.frozen
22+
class ErrorContainer:
23+
_by_error_str: dict[str, int] = attrs.field(init=False, factory=dict)
24+
_error_by_str: dict[str, InvalidPattern] = attrs.field(init=False, factory=dict)
25+
26+
def add(self, error: InvalidPattern) -> None:
27+
error_str = str(error)
28+
if error_str not in self._error_by_str:
29+
self._error_by_str[error_str] = error
30+
31+
if error_str not in self._by_error_str:
32+
self._by_error_str[error_str] = 0
33+
34+
self._by_error_str[error_str] += 1
35+
36+
def __iter__(self) -> Iterator[InvalidPattern]:
37+
yield from (self._error_by_str[error_str] for error_str in self._by_error_str)
38+
39+
@property
40+
def errors(self) -> Iterator[InvalidPattern]:
41+
yield from self
42+
43+
@property
44+
def by_most_repeated(self) -> Iterator[str]:
45+
yield from (
46+
error
47+
for error, _ in (
48+
sorted(
49+
self._by_error_str.items(),
50+
key=lambda pair: (-pair[1], pair[0].__class__.__name__),
51+
)
52+
)
53+
)
54+
55+
56+
@attrs.frozen
57+
class FoundInvalidPatterns(Exception):
58+
errors: ErrorContainer
59+
60+
def __str__(self) -> str:
61+
return "Found invalid patterns"
62+
63+
64+
@attrs.frozen(kw_only=True)
65+
class NoPositionalArguments(InvalidPattern):
66+
pattern: _raw_patterns.RawPattern
67+
where: _display.Where
68+
69+
def __str__(self) -> str:
70+
return f"[NoPositionalArguments]\n{self.where.display()}\n :: Please ensure that captured groups in url patterns always have a name"
71+
72+
73+
@attrs.frozen(kw_only=True)
74+
class MustSubclassDjangoGenericView(InvalidPattern):
75+
pattern: _raw_patterns.RawPattern
76+
where: _display.Where
77+
78+
def __str__(self) -> str:
79+
return f"[MustSubclassDjangoGenericView]\n{self.where.display(display_regex=False)}\n :: Views must inherit from django.views.generic.View"
80+
81+
82+
@attrs.frozen(kw_only=True)
83+
class RequiredArgOnViewNotAlwaysRequiredByPattern(InvalidPattern):
84+
pattern_wheres: Sequence[_display.Where]
85+
function_where: _Display
86+
missing_args: set[str]
87+
88+
def __str__(self) -> str:
89+
url_patterns = [" url patterns >>"]
90+
for i, where in enumerate(self.pattern_wheres):
91+
url_patterns.append(f" {i} >")
92+
url_patterns.extend(where.display(indent=" ").split("\n"))
93+
94+
return "\n".join(
95+
[
96+
"[RequiredArgOnViewNotAlwaysRequiredByPattern]",
97+
f" {self.function_where()}",
98+
f" missing_from_urlpatterns = {sorted(self.missing_args)}",
99+
"\n".join(url_patterns),
100+
" :: Found arguments on the view that are not provided by any of the patterns that lead to that view",
101+
" :: You likely want to use Unpack on the kwargs with a NotRequired on these args",
102+
" :: Or give them default values if you have provided explicit keywords to the function",
103+
]
104+
)
105+
106+
107+
@attrs.frozen(kw_only=True)
108+
class ViewDoesNotAcceptCapturedArg(InvalidPattern):
109+
where: _display.Where
110+
missing: Sequence[tuple[_display.Where, str]]
111+
function_where: _Display
112+
113+
def __str__(self) -> str:
114+
missing: list[str] = []
115+
for where, name in self.missing:
116+
missing.append(f" Missing captured arg: {name}\n{where.display(indent=' ')}")
117+
118+
return "\n".join(
119+
[
120+
"[ViewDoesNotAcceptCapturedArg]",
121+
f" Originating:\n{where.display(indent=' ')}",
122+
f" {self.function_where()}",
123+
"\n".join(missing),
124+
" :: There are args in the pattern that the view is not aware of",
125+
" :: You likely want to add those extra arguments to the view!",
126+
]
127+
)
128+
129+
130+
@attrs.frozen(kw_only=True)
131+
class InvalidRequestAnnotation(InvalidPattern, abc.ABC):
132+
where: _display.Where
133+
view_class: type
134+
request_annotation: object
135+
expected_user_type: type
136+
class_where: _Display
137+
acceptable_annotations: Sequence[object]
138+
acceptable_request_annotation_containers: Sequence[object]
139+
140+
@property
141+
def error(self) -> str:
142+
"""
143+
Create a specific error condition depending on what is wrong.
144+
145+
* If the annotation is one of the containers, then it was supplied without using square brackets on it
146+
* Ensure if we have a generic, it's one of the containers
147+
* Otherwise we must have the wrong user model inside the square brackets
148+
"""
149+
if self.request_annotation in self.acceptable_request_annotation_containers:
150+
return "The annotation is not specifying a user model"
151+
elif (
152+
self.request_annotation not in self.acceptable_annotations
153+
and typing.get_origin(self.request_annotation)
154+
not in self.acceptable_request_annotation_containers
155+
):
156+
return f"The annotation is not using a valid type\n ({self.request_annotation})"
157+
else:
158+
return "The annotation is specifying the wrong user model"
159+
160+
@property
161+
@abc.abstractmethod
162+
def expect(self) -> str:
163+
"""
164+
Return a simple string representing what the type should be
165+
"""
166+
167+
@property
168+
@abc.abstractmethod
169+
def expanded_note(self) -> Iterator[str]: ...
170+
171+
def __str__(self) -> str:
172+
return "\n".join(
173+
[
174+
"[InvalidRequestAnnotation]",
175+
f" Originating:\n{self.where.display(indent=' ')}",
176+
f" {self.class_where()}",
177+
f" error = {self.error}",
178+
f" expect = {self.expect}",
179+
*self.expanded_note,
180+
]
181+
)
182+
183+
184+
@attrs.frozen
185+
class MismatchedRequiredArgs(InvalidPattern):
186+
@attrs.frozen
187+
class Incorrect:
188+
reason: str
189+
add_auth_message: bool = False
190+
191+
@classmethod
192+
def missing(cls, name: str) -> Self:
193+
return cls(reason=f"Missing required positional argument: {name}")
194+
195+
@classmethod
196+
def misnamed(cls, *, index: int, got: str, want: str) -> Self:
197+
if index == 0:
198+
reason = f"The first argument should be named {want}, but got {got}"
199+
elif index == 1:
200+
reason = f"The second argument should be named {want}, but got {got}"
201+
else:
202+
reason = f"Require positional parameter {index} to be named {want}, but got {got}"
203+
return cls(reason=reason)
204+
205+
@classmethod
206+
def mistyped(
207+
cls, *, name: str, got: object, want: str, add_auth_message: bool = False
208+
) -> Self:
209+
return cls(
210+
reason=f"The '{name}' argument needs to be '{want}' but it's '{got}'",
211+
add_auth_message=add_auth_message,
212+
)
213+
214+
@classmethod
215+
def no_var_args(cls, *, name: str) -> Self:
216+
return cls(
217+
reason=f"Please remove the var args '*{name}' from the function signature and ensure all arguments are explicit"
218+
)
219+
220+
@classmethod
221+
def make_keyword_only(cls, *, name: str) -> Self:
222+
return cls(
223+
reason=f"Please ensure the '{name}' argument comes after a lone '*' so that it is defined as keyword only"
224+
)
225+
226+
function: _functions.DispatchFunction
227+
incorrect: Sequence[Incorrect]
228+
229+
def __str__(self) -> str:
230+
problems = "\n * ".join(inc.reason for inc in self.incorrect)
231+
add_auth_message = any(inc.add_auth_message for inc in self.incorrect)
232+
msg = (
233+
f"[MismatchedRequiredArgs]\n {self.function.display()}\n Found some problems with the arguments to some functions:\n * {problems}"
234+
"\n :: We want to create consistency around the names and types of the positional"
235+
"\n :: arguments to specific functions on the Django views"
236+
)
237+
if add_auth_message:
238+
msg = f"{msg}\n :: For Django class views, instead annotate request on the class and refer to `self.request.user`"
239+
240+
return msg
241+
242+
243+
@attrs.frozen
244+
class InvalidArgAnnotations(InvalidPattern):
245+
function_where: _Display
246+
where: _display.Where
247+
incorrect: list[tuple[str, object, object]]
248+
249+
def __str__(self) -> str:
250+
incorrect = []
251+
for name, view_annotation, pattern_annotation in self.incorrect:
252+
incorrect.append(
253+
f" * Expected '{name}' to be '{pattern_annotation}', found '{view_annotation}'"
254+
)
255+
256+
return "\n".join(
257+
[
258+
"[InvalidArgAnnotations]",
259+
f" Originating:\n{self.where.display(indent=' ')}",
260+
f" {self.function_where()}",
261+
" Found some args that have incorrect annotations:",
262+
"\n".join(incorrect),
263+
" :: When we defined url patterns we end up using converters that can change what",
264+
" :: type the view gets and we want to mirror this in our dispatch related signatures",
265+
]
266+
)
267+
268+
269+
@attrs.frozen
270+
class KwargsMustBeAnnotated(InvalidPattern):
271+
function: _functions.DispatchFunction
272+
arg_name: str
273+
allows_object: bool
274+
allows_any: bool
275+
276+
def __str__(self) -> str:
277+
msg = [
278+
f"Please ensure `**{self.arg_name}` has an annotation using typing.Unpack or specify keyword arguments explicitly"
279+
]
280+
if self.allows_object:
281+
msg.append(f"or use `**{self.arg_name}: object`")
282+
if self.allows_any:
283+
msg.append(f"or use `**{self.arg_name}: Any` from the typing module")
284+
285+
return f"[KwargsMustBeAnnotated]\n {self.function.display()}\n :: " + "\n :: ".join(msg)

0 commit comments

Comments
 (0)