converters.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. from __future__ import annotations
  2. import re
  3. import typing as t
  4. import uuid
  5. from urllib.parse import quote
  6. if t.TYPE_CHECKING:
  7. from .map import Map
  8. class ValidationError(ValueError):
  9. """Validation error. If a rule converter raises this exception the rule
  10. does not match the current URL and the next URL is tried.
  11. """
  12. class BaseConverter:
  13. """Base class for all converters.
  14. .. versionchanged:: 2.3
  15. ``part_isolating`` defaults to ``False`` if ``regex`` contains a ``/``.
  16. """
  17. regex = "[^/]+"
  18. weight = 100
  19. part_isolating = True
  20. def __init_subclass__(cls, **kwargs: t.Any) -> None:
  21. super().__init_subclass__(**kwargs)
  22. # If the converter isn't inheriting its regex, disable part_isolating by default
  23. # if the regex contains a / character.
  24. if "regex" in cls.__dict__ and "part_isolating" not in cls.__dict__:
  25. cls.part_isolating = "/" not in cls.regex
  26. def __init__(self, map: Map, *args: t.Any, **kwargs: t.Any) -> None:
  27. self.map = map
  28. def to_python(self, value: str) -> t.Any:
  29. return value
  30. def to_url(self, value: t.Any) -> str:
  31. # safe = https://url.spec.whatwg.org/#url-path-segment-string
  32. return quote(str(value), safe="!$&'()*+,/:;=@")
  33. class UnicodeConverter(BaseConverter):
  34. """This converter is the default converter and accepts any string but
  35. only one path segment. Thus the string can not include a slash.
  36. This is the default validator.
  37. Example::
  38. Rule('/pages/<page>'),
  39. Rule('/<string(length=2):lang_code>')
  40. :param map: the :class:`Map`.
  41. :param minlength: the minimum length of the string. Must be greater
  42. or equal 1.
  43. :param maxlength: the maximum length of the string.
  44. :param length: the exact length of the string.
  45. """
  46. def __init__(
  47. self,
  48. map: Map,
  49. minlength: int = 1,
  50. maxlength: int | None = None,
  51. length: int | None = None,
  52. ) -> None:
  53. super().__init__(map)
  54. if length is not None:
  55. length_regex = f"{{{int(length)}}}"
  56. else:
  57. if maxlength is None:
  58. maxlength_value = ""
  59. else:
  60. maxlength_value = str(int(maxlength))
  61. length_regex = f"{{{int(minlength)},{maxlength_value}}}"
  62. self.regex = f"[^/]{length_regex}"
  63. class AnyConverter(BaseConverter):
  64. """Matches one of the items provided. Items can either be Python
  65. identifiers or strings::
  66. Rule('/<any(about, help, imprint, class, "foo,bar"):page_name>')
  67. :param map: the :class:`Map`.
  68. :param items: this function accepts the possible items as positional
  69. arguments.
  70. .. versionchanged:: 2.2
  71. Value is validated when building a URL.
  72. """
  73. def __init__(self, map: Map, *items: str) -> None:
  74. super().__init__(map)
  75. self.items = set(items)
  76. self.regex = f"(?:{'|'.join([re.escape(x) for x in items])})"
  77. def to_url(self, value: t.Any) -> str:
  78. if value in self.items:
  79. return str(value)
  80. valid_values = ", ".join(f"'{item}'" for item in sorted(self.items))
  81. raise ValueError(f"'{value}' is not one of {valid_values}")
  82. class PathConverter(BaseConverter):
  83. """Like the default :class:`UnicodeConverter`, but it also matches
  84. slashes. This is useful for wikis and similar applications::
  85. Rule('/<path:wikipage>')
  86. Rule('/<path:wikipage>/edit')
  87. :param map: the :class:`Map`.
  88. """
  89. part_isolating = False
  90. regex = "[^/].*?"
  91. weight = 200
  92. class NumberConverter(BaseConverter):
  93. """Baseclass for `IntegerConverter` and `FloatConverter`.
  94. :internal:
  95. """
  96. weight = 50
  97. num_convert: t.Callable = int
  98. def __init__(
  99. self,
  100. map: Map,
  101. fixed_digits: int = 0,
  102. min: int | None = None,
  103. max: int | None = None,
  104. signed: bool = False,
  105. ) -> None:
  106. if signed:
  107. self.regex = self.signed_regex
  108. super().__init__(map)
  109. self.fixed_digits = fixed_digits
  110. self.min = min
  111. self.max = max
  112. self.signed = signed
  113. def to_python(self, value: str) -> t.Any:
  114. if self.fixed_digits and len(value) != self.fixed_digits:
  115. raise ValidationError()
  116. value = self.num_convert(value)
  117. if (self.min is not None and value < self.min) or (
  118. self.max is not None and value > self.max
  119. ):
  120. raise ValidationError()
  121. return value
  122. def to_url(self, value: t.Any) -> str:
  123. value = str(self.num_convert(value))
  124. if self.fixed_digits:
  125. value = value.zfill(self.fixed_digits)
  126. return value
  127. @property
  128. def signed_regex(self) -> str:
  129. return f"-?{self.regex}"
  130. class IntegerConverter(NumberConverter):
  131. """This converter only accepts integer values::
  132. Rule("/page/<int:page>")
  133. By default it only accepts unsigned, positive values. The ``signed``
  134. parameter will enable signed, negative values. ::
  135. Rule("/page/<int(signed=True):page>")
  136. :param map: The :class:`Map`.
  137. :param fixed_digits: The number of fixed digits in the URL. If you
  138. set this to ``4`` for example, the rule will only match if the
  139. URL looks like ``/0001/``. The default is variable length.
  140. :param min: The minimal value.
  141. :param max: The maximal value.
  142. :param signed: Allow signed (negative) values.
  143. .. versionadded:: 0.15
  144. The ``signed`` parameter.
  145. """
  146. regex = r"\d+"
  147. class FloatConverter(NumberConverter):
  148. """This converter only accepts floating point values::
  149. Rule("/probability/<float:probability>")
  150. By default it only accepts unsigned, positive values. The ``signed``
  151. parameter will enable signed, negative values. ::
  152. Rule("/offset/<float(signed=True):offset>")
  153. :param map: The :class:`Map`.
  154. :param min: The minimal value.
  155. :param max: The maximal value.
  156. :param signed: Allow signed (negative) values.
  157. .. versionadded:: 0.15
  158. The ``signed`` parameter.
  159. """
  160. regex = r"\d+\.\d+"
  161. num_convert = float
  162. def __init__(
  163. self,
  164. map: Map,
  165. min: float | None = None,
  166. max: float | None = None,
  167. signed: bool = False,
  168. ) -> None:
  169. super().__init__(map, min=min, max=max, signed=signed) # type: ignore
  170. class UUIDConverter(BaseConverter):
  171. """This converter only accepts UUID strings::
  172. Rule('/object/<uuid:identifier>')
  173. .. versionadded:: 0.10
  174. :param map: the :class:`Map`.
  175. """
  176. regex = (
  177. r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-"
  178. r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}"
  179. )
  180. def to_python(self, value: str) -> uuid.UUID:
  181. return uuid.UUID(value)
  182. def to_url(self, value: uuid.UUID) -> str:
  183. return str(value)
  184. #: the default converter mapping for the map.
  185. DEFAULT_CONVERTERS: t.Mapping[str, type[BaseConverter]] = {
  186. "default": UnicodeConverter,
  187. "string": UnicodeConverter,
  188. "any": AnyConverter,
  189. "path": PathConverter,
  190. "int": IntegerConverter,
  191. "float": FloatConverter,
  192. "uuid": UUIDConverter,
  193. }