1 from __future__
import annotations
6 from dataclasses
import dataclass
7 from string
import Template
8 from types
import CodeType
9 from urllib
.parse
import quote
11 from ..datastructures
import iter_multi_items
12 from ..urls
import _urlencode
13 from .converters
import ValidationError
16 from .converters
import BaseConverter
20 class Weighting(t
.NamedTuple
):
21 number_static_weights
: int
22 static_weights
: list[tuple[int, int]]
23 number_argument_weights
: int
24 argument_weights
: list[int]
31 Rules can be represented by parts as delimited by `/` with
32 instances of this class representing those parts. The *content* is
33 either the raw content if *static* or a regex string to match
34 against. The *weight* can be used to order parts when matching.
45 _part_re
= re
.compile(
48 (?P<slash>/) # a slash
50 (?P<static>[^</]+) # static rule data
55 (?P<converter>[a-zA-Z_][a-zA-Z0-9_]*) # converter name
56 (?:\((?P<arguments>.*?)\))? # converter arguments
57 : # variable delimiter
59 (?P<variable>[a-zA-Z_][a-zA-Z0-9_]*) # variable name
67 _simple_rule_re
= re
.compile(r
"<([^>]+)>")
68 _converter_args_re
= re
.compile(
70 ((?P<name>\w+)\s*=\s*)?
77 [urUR]?(?P<stringval>"[^"]*?"|'[^']*')
84 _PYTHON_CONSTANTS
= {"None": None, "True": True, "False": False}
87 def _find(value
: str, target
: str, pos
: int) -> int:
88 """Find the *target* in *value* after *pos*.
90 Returns the *value* length if *target* isn't found.
93 return value
.index(target
, pos
)
98 def _pythonize(value
: str) -> None |
bool |
int |
float |
str:
99 if value
in _PYTHON_CONSTANTS
:
100 return _PYTHON_CONSTANTS
[value
]
101 for convert
in int, float:
103 return convert(value
) # type: ignore
106 if value
[:1] == value
[-1:] and value
[0] in "\"'":
111 def parse_converter_args(argstr
: str) -> tuple[t
.Tuple
, dict[str, t
.Any
]]:
116 for item
in _converter_args_re
.finditer(argstr
):
117 value
= item
.group("stringval")
119 value
= item
.group("value")
120 value
= _pythonize(value
)
121 if not item
.group("name"):
124 name
= item
.group("name")
127 return tuple(args
), kwargs
131 """As soon as you have more complex URL setups it's a good idea to use rule
132 factories to avoid repetitive tasks. Some of them are builtin, others can
133 be added by subclassing `RuleFactory` and overriding `get_rules`.
136 def get_rules(self
, map: Map
) -> t
.Iterable
[Rule
]:
137 """Subclasses of `RuleFactory` have to override this method and return
138 an iterable of rules."""
139 raise NotImplementedError()
142 class Subdomain(RuleFactory
):
143 """All URLs provided by this factory have the subdomain set to a
144 specific domain. For example if you want to use the subdomain for
145 the current language this can be a good setup::
148 Rule('/', endpoint='#select_language'),
149 Subdomain('<string(length=2):lang_code>', [
150 Rule('/', endpoint='index'),
151 Rule('/about', endpoint='about'),
152 Rule('/help', endpoint='help')
156 All the rules except for the ``'#select_language'`` endpoint will now
157 listen on a two letter long subdomain that holds the language code
158 for the current request.
161 def __init__(self
, subdomain
: str, rules
: t
.Iterable
[RuleFactory
]) -> None:
162 self
.subdomain
= subdomain
165 def get_rules(self
, map: Map
) -> t
.Iterator
[Rule
]:
166 for rulefactory
in self
.rules
:
167 for rule
in rulefactory
.get_rules(map):
169 rule
.subdomain
= self
.subdomain
173 class Submount(RuleFactory
):
174 """Like `Subdomain` but prefixes the URL rule with a given string::
177 Rule('/', endpoint='index'),
179 Rule('/', endpoint='blog/index'),
180 Rule('/entry/<entry_slug>', endpoint='blog/show')
184 Now the rule ``'blog/show'`` matches ``/blog/entry/<entry_slug>``.
187 def __init__(self
, path
: str, rules
: t
.Iterable
[RuleFactory
]) -> None:
188 self
.path
= path
.rstrip("/")
191 def get_rules(self
, map: Map
) -> t
.Iterator
[Rule
]:
192 for rulefactory
in self
.rules
:
193 for rule
in rulefactory
.get_rules(map):
195 rule
.rule
= self
.path
+ rule
.rule
199 class EndpointPrefix(RuleFactory
):
200 """Prefixes all endpoints (which must be strings for this factory) with
201 another string. This can be useful for sub applications::
204 Rule('/', endpoint='index'),
205 EndpointPrefix('blog/', [Submount('/blog', [
206 Rule('/', endpoint='index'),
207 Rule('/entry/<entry_slug>', endpoint='show')
212 def __init__(self
, prefix
: str, rules
: t
.Iterable
[RuleFactory
]) -> None:
216 def get_rules(self
, map: Map
) -> t
.Iterator
[Rule
]:
217 for rulefactory
in self
.rules
:
218 for rule
in rulefactory
.get_rules(map):
220 rule
.endpoint
= self
.prefix
+ rule
.endpoint
225 """Returns copies of the rules wrapped and expands string templates in
226 the endpoint, rule, defaults or subdomain sections.
228 Here a small example for such a rule template::
230 from werkzeug.routing import Map, Rule, RuleTemplate
232 resource = RuleTemplate([
233 Rule('/$name/', endpoint='$name.list'),
234 Rule('/$name/<int:id>', endpoint='$name.show')
237 url_map = Map([resource(name='user'), resource(name='page')])
239 When a rule template is called the keyword arguments are used to
240 replace the placeholders in all the string parameters.
243 def __init__(self
, rules
: t
.Iterable
[Rule
]) -> None:
244 self
.rules
= list(rules
)
246 def __call__(self
, *args
: t
.Any
, **kwargs
: t
.Any
) -> RuleTemplateFactory
:
247 return RuleTemplateFactory(self
.rules
, dict(*args
, **kwargs
))
250 class RuleTemplateFactory(RuleFactory
):
251 """A factory that fills in template variables into rules. Used by
252 `RuleTemplate` internally.
258 self
, rules
: t
.Iterable
[RuleFactory
], context
: dict[str, t
.Any
]
261 self
.context
= context
263 def get_rules(self
, map: Map
) -> t
.Iterator
[Rule
]:
264 for rulefactory
in self
.rules
:
265 for rule
in rulefactory
.get_rules(map):
266 new_defaults
= subdomain
= None
269 for key
, value
in rule
.defaults
.items():
270 if isinstance(value
, str):
271 value
= Template(value
).substitute(self
.context
)
272 new_defaults
[key
] = value
273 if rule
.subdomain
is not None:
274 subdomain
= Template(rule
.subdomain
).substitute(self
.context
)
275 new_endpoint
= rule
.endpoint
276 if isinstance(new_endpoint
, str):
277 new_endpoint
= Template(new_endpoint
).substitute(self
.context
)
279 Template(rule
.rule
).substitute(self
.context
),
289 def _prefix_names(src
: str) -> ast
.stmt
:
290 """ast parse and prefix names with `.` to avoid collision with user vars"""
291 tree
= ast
.parse(src
).body
[0]
292 if isinstance(tree
, ast
.Expr
):
293 tree
= tree
.value
# type: ignore
294 for node
in ast
.walk(tree
):
295 if isinstance(node
, ast
.Name
):
296 node
.id = f
".{node.id}"
300 _CALL_CONVERTER_CODE_FMT
= "self._converters[{elem!r}].to_url()"
301 _IF_KWARGS_URL_ENCODE_CODE
= """\
303 params = self._encode_query_vars(kwargs)
304 q = "?" if params else ""
308 _IF_KWARGS_URL_ENCODE_AST
= _prefix_names(_IF_KWARGS_URL_ENCODE_CODE
)
309 _URL_ENCODE_AST_NAMES
= (_prefix_names("q"), _prefix_names("params"))
312 class Rule(RuleFactory
):
313 """A Rule represents one URL pattern. There are some options for `Rule`
314 that change the way it behaves and are passed to the `Rule` constructor.
315 Note that besides the rule-string all arguments *must* be keyword arguments
316 in order to not break the application on Werkzeug upgrades.
319 Rule strings basically are just normal URL paths with placeholders in
320 the format ``<converter(arguments):name>`` where the converter and the
321 arguments are optional. If no converter is defined the `default`
322 converter is used which means `string` in the normal configuration.
324 URL rules that end with a slash are branch URLs, others are leaves.
325 If you have `strict_slashes` enabled (which is the default), all
326 branch URLs that are matched without a trailing slash will trigger a
327 redirect to the same URL with the missing slash appended.
329 The converters are defined on the `Map`.
332 The endpoint for this rule. This can be anything. A reference to a
333 function, a string, a number etc. The preferred way is using a string
334 because the endpoint is used for URL generation.
337 An optional dict with defaults for other rules with the same endpoint.
338 This is a bit tricky but useful if you want to have unique URLs::
341 Rule('/all/', defaults={'page': 1}, endpoint='all_entries'),
342 Rule('/all/page/<int:page>', endpoint='all_entries')
345 If a user now visits ``http://example.com/all/page/1`` they will be
346 redirected to ``http://example.com/all/``. If `redirect_defaults` is
347 disabled on the `Map` instance this will only affect the URL
351 The subdomain rule string for this rule. If not specified the rule
352 only matches for the `default_subdomain` of the map. If the map is
353 not bound to a subdomain this feature is disabled.
355 Can be useful if you want to have user profiles on different subdomains
356 and all subdomains are forwarded to your application::
359 Rule('/', subdomain='<username>', endpoint='user/homepage'),
360 Rule('/stats', subdomain='<username>', endpoint='user/stats')
364 A sequence of http methods this rule applies to. If not specified, all
365 methods are allowed. For example this can be useful if you want different
366 endpoints for `POST` and `GET`. If methods are defined and the path
367 matches but the method matched against is not in this list or in the
368 list of another rule for that path the error raised is of the type
369 `MethodNotAllowed` rather than `NotFound`. If `GET` is present in the
370 list of methods and `HEAD` is not, `HEAD` is added automatically.
373 Override the `Map` setting for `strict_slashes` only for this rule. If
374 not specified the `Map` setting is used.
377 Override :attr:`Map.merge_slashes` for this rule.
380 Set this to True and the rule will never match but will create a URL
381 that can be build. This is useful if you have resources on a subdomain
382 or folder that are not handled by the WSGI application (like static data)
385 If given this must be either a string or callable. In case of a
386 callable it's called with the url adapter that triggered the match and
387 the values of the URL as keyword arguments and has to return the target
388 for the redirect, otherwise it has to be a string with placeholders in
391 def foo_with_slug(adapter, id):
392 # ask the database for the slug for the old id. this of
393 # course has nothing to do with werkzeug.
394 return f'foo/{Foo.get_slug_for_id(id)}'
397 Rule('/foo/<slug>', endpoint='foo'),
398 Rule('/some/old/url/<slug>', redirect_to='foo/<slug>'),
399 Rule('/other/old/url/<int:id>', redirect_to=foo_with_slug)
402 When the rule is matched the routing system will raise a
403 `RequestRedirect` exception with the target for the redirect.
405 Keep in mind that the URL will be joined against the URL root of the
406 script so don't use a leading slash on the target URL unless you
407 really mean root of that domain.
410 If enabled this rule serves as an alias for another rule with the same
411 endpoint and arguments.
414 If provided and the URL map has host matching enabled this can be
415 used to provide a match rule for the whole host. This also means
416 that the subdomain feature is disabled.
419 If ``True``, this rule is only matches for WebSocket (``ws://``,
420 ``wss://``) requests. By default, rules will only match for HTTP
423 .. versionchanged:: 2.1
424 Percent-encoded newlines (``%0a``), which are decoded by WSGI
425 servers, are considered when routing instead of terminating the
428 .. versionadded:: 1.0
431 .. versionadded:: 1.0
432 Added ``merge_slashes``.
434 .. versionadded:: 0.7
435 Added ``alias`` and ``host``.
437 .. versionchanged:: 0.6.1
438 ``HEAD`` is added to ``methods`` if ``GET`` is present.
444 defaults
: t
.Mapping
[str, t
.Any
] |
None = None,
445 subdomain
: str |
None = None,
446 methods
: t
.Iterable
[str] |
None = None,
447 build_only
: bool = False,
448 endpoint
: str |
None = None,
449 strict_slashes
: bool |
None = None,
450 merge_slashes
: bool |
None = None,
451 redirect_to
: str | t
.Callable
[..., str] |
None = None,
453 host
: str |
None = None,
454 websocket
: bool = False,
456 if not string
.startswith("/"):
457 raise ValueError(f
"URL rule '{string}' must start with a slash.")
460 self
.is_leaf
= not string
.endswith("/")
461 self
.is_branch
= string
.endswith("/")
463 self
.map: Map
= None # type: ignore
464 self
.strict_slashes
= strict_slashes
465 self
.merge_slashes
= merge_slashes
466 self
.subdomain
= subdomain
468 self
.defaults
= defaults
469 self
.build_only
= build_only
471 self
.websocket
= websocket
473 if methods
is not None:
474 if isinstance(methods
, str):
475 raise TypeError("'methods' should be a list of strings.")
477 methods
= {x.upper() for x in methods}
479 if "HEAD" not in methods
and "GET" in methods
:
482 if websocket
and methods
- {"GET", "HEAD", "OPTIONS"}
:
484 "WebSocket rules can only use 'GET', 'HEAD', and 'OPTIONS' methods."
487 self
.methods
= methods
488 self
.endpoint
: str = endpoint
# type: ignore
489 self
.redirect_to
= redirect_to
492 self
.arguments
= set(map(str, defaults
))
494 self
.arguments
= set()
496 self
._converters
: dict[str, BaseConverter
] = {}
497 self
._trace
: list[tuple[bool, str]] = []
498 self
._parts
: list[RulePart
] = []
500 def empty(self
) -> Rule
:
502 Return an unbound copy of this rule.
504 This can be useful if want to reuse an already bound URL for another
505 map. See ``get_empty_kwargs`` to override what keyword arguments are
506 provided to the new copy.
508 return type(self
)(self
.rule
, **self
.get_empty_kwargs())
510 def get_empty_kwargs(self
) -> t
.Mapping
[str, t
.Any
]:
512 Provides kwargs for instantiating empty copy with empty()
514 Use this method to provide custom keyword arguments to the subclass of
515 ``Rule`` when calling ``some_rule.empty()``. Helpful when the subclass
516 has custom keyword arguments that are needed at instantiation.
518 Must return a ``dict`` that will be provided as kwargs to the new
519 instance of ``Rule``, following the initial ``self.rule`` value which
520 is always provided as the first, required positional argument.
524 defaults
= dict(self
.defaults
)
527 subdomain
=self
.subdomain
,
528 methods
=self
.methods
,
529 build_only
=self
.build_only
,
530 endpoint
=self
.endpoint
,
531 strict_slashes
=self
.strict_slashes
,
532 redirect_to
=self
.redirect_to
,
537 def get_rules(self
, map: Map
) -> t
.Iterator
[Rule
]:
540 def refresh(self
) -> None:
541 """Rebinds and refreshes the URL. Call this if you modified the
546 self
.bind(self
.map, rebind
=True)
548 def bind(self
, map: Map
, rebind
: bool = False) -> None:
549 """Bind the url to a map and create a regular expression based on
550 the information from the rule itself and the defaults from the map.
554 if self
.map is not None and not rebind
:
555 raise RuntimeError(f
"url rule {self!r} already bound to map {self.map!r}")
557 if self
.strict_slashes
is None:
558 self
.strict_slashes
= map.strict_slashes
559 if self
.merge_slashes
is None:
560 self
.merge_slashes
= map.merge_slashes
561 if self
.subdomain
is None:
562 self
.subdomain
= map.default_subdomain
570 kwargs
: t
.Mapping
[str, t
.Any
],
572 """Looks up the converter for the given parameter.
574 .. versionadded:: 0.9
576 if converter_name
not in self
.map.converters
:
577 raise LookupError(f
"the converter {converter_name!r} does not exist")
578 return self
.map.converters
[converter_name
](self
.map, *args
, **kwargs
)
580 def _encode_query_vars(self
, query_vars
: t
.Mapping
[str, t
.Any
]) -> str:
581 items
: t
.Iterable
[tuple[str, str]] = iter_multi_items(query_vars
)
583 if self
.map.sort_parameters
:
584 items
= sorted(items
, key
=self
.map.sort_key
)
586 return _urlencode(items
)
588 def _parse_rule(self
, rule
: str) -> t
.Iterable
[RulePart
]:
591 argument_weights
= []
592 static_weights
: list[tuple[int, int]] = []
597 while pos
< len(rule
):
598 match
= _part_re
.match(rule
, pos
)
600 raise ValueError(f
"malformed url rule: {rule!r}")
602 data
= match
.groupdict()
603 if data
["static"] is not None:
604 static_weights
.append((len(static_weights
), -len(data
["static"])))
605 self
._trace
.append((False, data
["static"]))
606 content
+= data
["static"] if static
else re
.escape(data
["static"])
608 if data
["variable"] is not None:
610 # Switching content to represent regex, hence the need to escape
611 content
= re
.escape(content
)
613 c_args
, c_kwargs
= parse_converter_args(data
["arguments"] or "")
614 convobj
= self
.get_converter(
615 data
["variable"], data
["converter"] or "default", c_args
, c_kwargs
617 self
._converters
[data
["variable"]] = convobj
618 self
.arguments
.add(data
["variable"])
619 if not convobj
.part_isolating
:
621 content
+= f
"(?P<__werkzeug_{convertor_number}>{convobj.regex})"
622 convertor_number
+= 1
623 argument_weights
.append(convobj
.weight
)
624 self
._trace
.append((True, data
["variable"]))
626 if data
["slash"] is not None:
627 self
._trace
.append((False, "/"))
634 -len(static_weights
),
636 -len(argument_weights
),
648 argument_weights
= []
656 if final
and content
[-1] == "/":
657 # If a converter is part_isolating=False (matches slashes) and ends with a
658 # slash, augment the regex to support slash redirects.
660 content
= content
[:-1] + "(?<!/)(/?)"
664 -len(static_weights
),
666 -len(argument_weights
),
678 content
="", final
=False, static
=True, suffixed
=False, weight
=weight
681 def compile(self
) -> None:
682 """Compiles the regular expression and stores it."""
683 assert self
.map is not None, "rule not bound"
685 if self
.map.host_matching
:
686 domain_rule
= self
.host
or ""
688 domain_rule
= self
.subdomain
or ""
691 self
._converters
= {}
692 if domain_rule
== "":
699 weight
=Weighting(0, [], 0, []),
703 self
._parts
.extend(self
._parse
_rule
(domain_rule
))
704 self
._trace
.append((False, "|"))
706 if self
.merge_slashes
:
707 rule
= re
.sub("/{2,}?", "/", self
.rule
)
708 self
._parts
.extend(self
._parse
_rule
(rule
))
710 self
._build
: t
.Callable
[..., tuple[str, str]]
711 self
._build
= self
._compile
_builder
(False).__get
__(self
, None)
712 self
._build
_unknown
: t
.Callable
[..., tuple[str, str]]
713 self
._build
_unknown
= self
._compile
_builder
(True).__get
__(self
, None)
716 def _get_func_code(code
: CodeType
, name
: str) -> t
.Callable
[..., tuple[str, str]]:
717 globs
: dict[str, t
.Any
] = {}
718 locs
: dict[str, t
.Any
] = {}
719 exec(code
, globs
, locs
)
720 return locs
[name
] # type: ignore
722 def _compile_builder(
723 self
, append_unknown
: bool = True
724 ) -> t
.Callable
[..., tuple[str, str]]:
725 defaults
= self
.defaults
or {}
726 dom_ops
: list[tuple[bool, str]] = []
727 url_ops
: list[tuple[bool, str]] = []
730 for is_dynamic
, data
in self
._trace
:
731 if data
== "|" and opl
is dom_ops
:
734 # this seems like a silly case to ever come up but:
735 # if a default is given for a value that appears in the rule,
736 # resolve it to a constant ahead of time
737 if is_dynamic
and data
in defaults
:
738 data
= self
._converters
[data
].to_url(defaults
[data
])
739 opl
.append((False, data
))
741 # safe = https://url.spec.whatwg.org/#url-path-segment-string
742 opl
.append((False, quote(data
, safe
="!$&'()*+,/:;=@")))
744 opl
.append((True, data
))
746 def _convert(elem
: str) -> ast
.stmt
:
747 ret
= _prefix_names(_CALL_CONVERTER_CODE_FMT
.format(elem
=elem
))
748 ret
.args
= [ast
.Name(str(elem
), ast
.Load())] # type: ignore # str for py2
751 def _parts(ops
: list[tuple[bool, str]]) -> list[ast
.AST
]:
753 _convert(elem
) if is_dynamic
else ast
.Constant(elem
)
754 for is_dynamic
, elem
in ops
756 parts
= parts
or [ast
.Constant("")]
760 if isinstance(p
, ast
.Constant
) and isinstance(ret
[-1], ast
.Constant
):
761 ret
[-1] = ast
.Constant(ret
[-1].value
+ p
.value
)
766 dom_parts
= _parts(dom_ops
)
767 url_parts
= _parts(url_ops
)
768 if not append_unknown
:
771 body
= [_IF_KWARGS_URL_ENCODE_AST
]
772 url_parts
.extend(_URL_ENCODE_AST_NAMES
)
774 def _join(parts
: list[ast
.AST
]) -> ast
.AST
:
775 if len(parts
) == 1: # shortcut
777 return ast
.JoinedStr(parts
)
780 ast
.Return(ast
.Tuple([_join(dom_parts
), _join(url_parts
)], ast
.Load()))
785 for is_dynamic
, elem
in dom_ops
+ url_ops
786 if is_dynamic
and elem
not in defaults
788 kargs
= [str(k
) for k
in defaults
]
790 func_ast
: ast
.FunctionDef
= _prefix_names("def _(): pass") # type: ignore
791 func_ast
.name
= f
"<builder:{self.rule!r}>"
792 func_ast
.args
.args
.append(ast
.arg(".self", None))
793 for arg
in pargs
+ kargs
:
794 func_ast
.args
.args
.append(ast
.arg(arg
, None))
795 func_ast
.args
.kwarg
= ast
.arg(".kwargs", None)
797 func_ast
.args
.defaults
.append(ast
.Constant(""))
800 # Use `ast.parse` instead of `ast.Module` for better portability, since the
801 # signature of `ast.Module` can change.
802 module
= ast
.parse("")
803 module
.body
= [func_ast
]
805 # mark everything as on line 1, offset 0
806 # less error-prone than `ast.fix_missing_locations`
807 # bad line numbers cause an assert to fail in debug builds
808 for node
in ast
.walk(module
):
809 if "lineno" in node
._attributes
:
811 if "end_lineno" in node
._attributes
:
812 node
.end_lineno
= node
.lineno
813 if "col_offset" in node
._attributes
:
815 if "end_col_offset" in node
._attributes
:
816 node
.end_col_offset
= node
.col_offset
818 code
= compile(module
, "<werkzeug routing>", "exec")
819 return self
._get
_func
_code
(code
, func_ast
.name
)
822 self
, values
: t
.Mapping
[str, t
.Any
], append_unknown
: bool = True
823 ) -> tuple[str, str] |
None:
824 """Assembles the relative url for that rule and the subdomain.
825 If building doesn't work for some reasons `None` is returned.
831 return self
._build
_unknown
(**values
)
833 return self
._build
(**values
)
834 except ValidationError
:
837 def provides_defaults_for(self
, rule
: Rule
) -> bool:
838 """Check if this rule has defaults for a given rule.
845 and self
.endpoint
== rule
.endpoint
847 and self
.arguments
== rule
.arguments
851 self
, values
: t
.Mapping
[str, t
.Any
], method
: str |
None = None
853 """Check if the dict of values has enough data for url generation.
857 # if a method was given explicitly and that method is not supported
858 # by this rule, this rule is not suitable.
861 and self
.methods
is not None
862 and method
not in self
.methods
866 defaults
= self
.defaults
or ()
868 # all arguments required must be either in the defaults dict or
869 # the value dictionary otherwise it's not suitable
870 for key
in self
.arguments
:
871 if key
not in defaults
and key
not in values
:
874 # in case defaults are given we ensure that either the value was
875 # skipped or the value is the same as the default value.
877 for key
, value
in defaults
.items():
878 if key
in values
and value
!= values
[key
]:
883 def build_compare_key(self
) -> tuple[int, int, int]:
884 """The build compare key for sorting.
888 return (1 if self
.alias
else 0, -len(self
.arguments
), -len(self
.defaults
or ()))
890 def __eq__(self
, other
: object) -> bool:
891 return isinstance(other
, type(self
)) and self
._trace
== other
._trace
893 __hash__
= None # type: ignore
895 def __str__(self
) -> str:
898 def __repr__(self
) -> str:
900 return f
"<{type(self).__name__} (unbound)>"
902 for is_dynamic
, data
in self
._trace
:
904 parts
.append(f
"<{data}>")
907 parts
= "".join(parts
).lstrip("|")
908 methods
= f
" ({', '.join(self.methods)})" if self
.methods
is not None else ""
909 return f
"<{type(self).__name__} {parts!r}{methods} -> {self.endpoint}>"