Added github integration

This commit is contained in:
2025-12-02 14:32:10 +00:00
parent b6dd8b8fe2
commit 4076c4bf83
762 changed files with 193089 additions and 2 deletions

View File

@@ -0,0 +1,28 @@
"""Flask-Limiter extension for rate limiting."""
from __future__ import annotations
from . import _version
from ._extension import Limiter, RequestLimit
from ._limits import (
ApplicationLimit,
Limit,
MetaLimit,
RouteLimit,
)
from .constants import ExemptionScope, HeaderNames
from .errors import RateLimitExceeded
__all__ = [
"ExemptionScope",
"HeaderNames",
"Limiter",
"Limit",
"RouteLimit",
"ApplicationLimit",
"MetaLimit",
"RateLimitExceeded",
"RequestLimit",
]
__version__ = _version.__version__

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
import flask
from flask.ctx import RequestContext
# flask.globals.request_ctx is only available in Flask >= 2.2.0
try:
from flask.globals import request_ctx
except ImportError:
request_ctx = None
def request_context() -> RequestContext:
if request_ctx is None:
return flask._request_ctx_stack.top
return request_ctx

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,406 @@
from __future__ import annotations
import dataclasses
import itertools
import traceback
import weakref
from functools import wraps
from types import TracebackType
from typing import TYPE_CHECKING, cast, overload
import flask
from flask import request
from flask.wrappers import Response
from limits import RateLimitItem, parse_many
from ._typing import Callable, Iterable, Iterator, P, R, Self, Sequence
from .util import get_qualified_name
if TYPE_CHECKING:
from flask_limiter import Limiter, RequestLimit
@dataclasses.dataclass(eq=True, unsafe_hash=True)
class RuntimeLimit:
"""
Final representation of a rate limit before it is triggered during a request
"""
limit: RateLimitItem
key_func: Callable[[], str]
scope: str | Callable[[str], str] | None
per_method: bool = False
methods: Sequence[str] | None = None
error_message: str | Callable[[], str] | None = None
exempt_when: Callable[[], bool] | None = None
override_defaults: bool | None = False
deduct_when: Callable[[Response], bool] | None = None
on_breach: Callable[[RequestLimit], Response | None] | None = None
cost: Callable[[], int] | int = 1
shared: bool = False
meta_limits: tuple[RuntimeLimit, ...] | None = None
def __post_init__(self) -> None:
if self.methods:
self.methods = tuple([k.lower() for k in self.methods])
@property
def is_exempt(self) -> bool:
"""Check if the limit is exempt."""
if self.exempt_when:
return self.exempt_when()
return False
@property
def deduction_amount(self) -> int:
"""How much to deduct from the limit"""
return self.cost() if callable(self.cost) else self.cost
@property
def method_exempt(self) -> bool:
"""Check if the limit is not applicable for this method"""
return self.methods is not None and request.method.lower() not in self.methods
def scope_for(self, endpoint: str, method: str | None) -> str:
"""
Derive final bucket (scope) for this limit given the endpoint and request method.
If the limit is shared between multiple routes, the scope does not include the endpoint.
"""
limit_scope = self.scope(request.endpoint or "") if callable(self.scope) else self.scope
if limit_scope:
if self.shared:
scope = limit_scope
else:
scope = f"{endpoint}:{limit_scope}"
else:
scope = endpoint
if self.per_method:
assert method
scope += f":{method.upper()}"
return scope
@dataclasses.dataclass(eq=True, unsafe_hash=True)
class Limit:
"""
The definition of a rate limit to be used by the extension as a default limit::
def default_key_function():
return request.remote_addr
def username_key_function():
return request.headers.get("username", "guest")
limiter = flask_limiter.Limiter(
default_key_function,
default_limits = [
# 10/second by username
flask_limiter.Limit("10/second", key_function=username_key_function),
# 100/second by ip (i.e. default_key_function)
flask_limiter.Limit("100/second),
]
)
limit.init_app(app)
- For application wide limits see :class:`ApplicationLimit`
- For meta limits see :class:`MetaLimit`
"""
#: Rate limit string or a callable that returns a string.
#: :ref:`ratelimit-string` for more details.
limit_provider: Callable[[], str] | str
#: Callable to extract the unique identifier for the rate limit.
#: If not provided the key_function will default to the key function
#: that the :class:`Limiter` was initialized with (:paramref:`Limiter.key_func`)
key_function: Callable[[], str] | None = None
#: A string or callable that returns a unique scope for the rate limit.
#: The scope is combined with current endpoint of the request if
#: :paramref:`shared` is ``False``
scope: str | Callable[[str], str] | None = None
#: The cost of a hit or a function that
#: takes no parameters and returns the cost as an integer (Default: ``1``).
cost: Callable[[], int] | int | None = None
#: If this a shared limit (i.e. to be used by different endpoints)
shared: bool = False
#: If specified, only the methods in this list will
#: be rate limited.
methods: Sequence[str] | None = None
#: Whether the limit is sub categorized into the
#: http method of the request.
per_method: bool = False
#: String (or callable that returns one) to override
#: the error message used in the response.
error_message: str | Callable[[], str] | None = None
#: Meta limits to trigger everytime this rate limit definition is exceeded
meta_limits: Iterable[Callable[[], str] | str | MetaLimit] | None = None
#: Callable used to decide if the rate
#: limit should skipped.
exempt_when: Callable[[], bool] | None = None
#: A function that receives the current
#: :class:`flask.Response` object and returns True/False to decide if a
#: deduction should be done from the rate limit
deduct_when: Callable[[Response], bool] | None = None
#: A function that will be called when this limit
#: is breached. If the function returns an instance of :class:`flask.Response`
#: that will be the response embedded into the :exc:`RateLimitExceeded` exception
#: raised.
on_breach: Callable[[RequestLimit], Response | None] | None = None
#: Whether the decorated limit overrides
#: the default limits (Default: ``True``).
#:
#: .. note:: When used with a :class:`~flask.Blueprint` the meaning
#: of the parameter extends to any parents the blueprint instance is
#: registered under. For more details see :ref:`recipes:nested blueprints`
#:
#: :meta private:
override_defaults: bool | None = dataclasses.field(default=False, init=False)
#: Weak reference to the limiter that this limit definition is bound to
#:
#: :meta private:
limiter: weakref.ProxyType[Limiter] = dataclasses.field(
init=False, hash=False, kw_only=True, repr=False
)
#: :meta private:
finalized: bool = dataclasses.field(default=True)
def __post_init__(self) -> None:
if self.methods:
self.methods = tuple([k.lower() for k in self.methods])
if self.meta_limits:
self.meta_limits = tuple(self.meta_limits)
def __iter__(self) -> Iterator[RuntimeLimit]:
limit_str = self.limit_provider() if callable(self.limit_provider) else self.limit_provider
limit_items = parse_many(limit_str) if limit_str else []
meta_limits: tuple[RuntimeLimit, ...] = ()
if self.meta_limits:
meta_limits = tuple(
itertools.chain(
*[
list(
MetaLimit(meta_limit).bind_parent(self)
if not isinstance(meta_limit, MetaLimit)
else meta_limit
)
for meta_limit in self.meta_limits
]
)
)
for limit in limit_items:
yield RuntimeLimit(
limit,
self.limit_by,
scope=self.scope,
per_method=self.per_method,
methods=self.methods,
error_message=self.error_message,
exempt_when=self.exempt_when,
deduct_when=self.deduct_when,
override_defaults=self.override_defaults,
on_breach=self.on_breach,
cost=self.cost or 1,
shared=self.shared,
meta_limits=meta_limits,
)
@property
def limit_by(self) -> Callable[[], str]:
return self.key_function or self.limiter._key_func
def bind(self: Self, limiter: Limiter) -> Self:
"""
Returns an instance of the limit definition that binds to a weak reference of an instance
of :class:`Limiter`.
:meta private:
"""
self.limiter = weakref.proxy(limiter)
[
meta_limit.bind(limiter)
for meta_limit in self.meta_limits or ()
if isinstance(meta_limit, MetaLimit)
]
return self
@dataclasses.dataclass(unsafe_hash=True, kw_only=True)
class RouteLimit(Limit):
"""
A variant of :class:`Limit` that can be used to to decorate a flask route or blueprint directly
instead of by using :meth:`Limiter.limit` or :meth:`Limiter.shared_limit`.
Decorating individual routes::
limiter = flask_limiter.Limiter(.....)
limiter.init_app(app)
@app.route("/")
@flask_limiter.RouteLimit("2/second", limiter=limiter)
def view_function():
...
"""
#: Whether the decorated limit overrides
#: the default limits (Default: ``True``).
#:
#: .. note:: When used with a :class:`~flask.Blueprint` the meaning
#: of the parameter extends to any parents the blueprint instance is
#: registered under. For more details see :ref:`recipes:nested blueprints`
override_defaults: bool | None = False
limiter: dataclasses.InitVar[Limiter] = dataclasses.field(hash=False)
def __post_init__(self, limiter: Limiter) -> None:
self.bind(limiter)
super().__post_init__()
def __enter__(self) -> None:
tb = traceback.extract_stack(limit=2)
qualified_location = f"{tb[0].filename}:{tb[0].name}:{tb[0].lineno}"
# TODO: if use as a context manager becomes interesting/valuable
# a less hacky approach than using the traceback and piggy backing
# on the limit manager's knowledge of decorated limits might be worth it.
self.limiter.limit_manager.add_decorated_limit(qualified_location, self, override=True)
self.limiter.limit_manager.add_endpoint_hint(
self.limiter.identify_request(), qualified_location
)
self.limiter._check_request_limit(in_middleware=False, callable_name=qualified_location)
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None: ...
@overload
def __call__(self, obj: Callable[P, R]) -> Callable[P, R]: ...
@overload
def __call__(self, obj: flask.Blueprint) -> None: ...
def __call__(self, obj: Callable[P, R] | flask.Blueprint) -> Callable[P, R] | None:
if isinstance(obj, flask.Blueprint):
name = obj.name
else:
name = get_qualified_name(obj)
if isinstance(obj, flask.Blueprint):
self.limiter.limit_manager.add_blueprint_limit(name, self)
return None
else:
self.limiter._marked_for_limiting.add(name)
self.limiter.limit_manager.add_decorated_limit(name, self)
@wraps(obj)
def __inner(*a: P.args, **k: P.kwargs) -> R:
if not getattr(obj, "__wrapper-limiter-instance", None) == self.limiter:
identity = self.limiter.identify_request()
if identity:
view_func = flask.current_app.view_functions.get(identity, None)
if view_func and not get_qualified_name(view_func) == name:
self.limiter.limit_manager.add_endpoint_hint(identity, name)
self.limiter._check_request_limit(in_middleware=False, callable_name=name)
return cast(R, flask.current_app.ensure_sync(obj)(*a, **k))
# mark this wrapper as wrapped by a decorator from the limiter
# from which the decorator was created. This ensures that stacked
# decorations only trigger rate limiting from the inner most
# decorator from each limiter instance (the weird need for
# keeping track of the instance is to handle cases where multiple
# limiter extensions are registered on the same application).
setattr(__inner, "__wrapper-limiter-instance", self.limiter)
return __inner
@dataclasses.dataclass(kw_only=True, unsafe_hash=True)
class ApplicationLimit(Limit):
"""
Variant of :class:`Limit` to be used for declaring an application wide limit that can be passed
to :class:`Limiter` as one of the members of :paramref:`Limiter.application_limits`
"""
#: The scope to use for the application wide limit
scope: str | Callable[[str], str] | None = dataclasses.field(default="global")
#: Application limits are always "shared"
#:
#: :meta private:
shared: bool = dataclasses.field(init=False, default=True)
@dataclasses.dataclass(kw_only=True, unsafe_hash=True)
class MetaLimit(Limit):
"""
Variant of :class:`Limit` to be used for declaring a meta limit that can be passed to
either :class:`Limiter` as one of the members of :paramref:`Limiter.meta_limits` or to another
instance of :class:`Limit` as a member of :paramref:`Limit.meta_limits`
"""
#: The scope to use for the meta limit
scope: str | Callable[[str], str] | None = dataclasses.field(default="meta")
#: meta limits can't have meta limits - at least here :)
#:
#: :meta private:
meta_limits: Sequence[Callable[[], str] | str | MetaLimit] | None = dataclasses.field(
init=False, default=None
)
#: The rate limit this meta limit is limiting.
#:
# :meta private:
parent_limit: Limit | None = dataclasses.field(init=False, default=None)
#: Meta limits are always "shared"
#:
#: :meta private:
shared: bool = dataclasses.field(init=False, default=True)
#: Meta limits can't have conditional deductions
#:
#: :meta private:
deduct_when: Callable[[Response], bool] | None = dataclasses.field(init=False, default=None)
#: Callable to extract the unique identifier for the rate limit.
#: If not provided the key_function will fallback to:
#:
#: - the key function of the parent limit this meta limit is declared for
#: - the key function for the :class:`Limiter` instance this meta limit
#: is eventually used with.
key_function: Callable[[], str] | None = None
@property
def limit_by(self) -> Callable[[], str]:
return (
self.key_function
or self.parent_limit
and self.parent_limit.key_function
or self.limiter._key_func
)
def bind_parent(self: Self, parent: Limit) -> Self:
"""
Binds this meta limit to be associated as a child of the ``parent`` limit.
:meta private:
"""
self.parent_limit = parent
return self

View File

@@ -0,0 +1,243 @@
from __future__ import annotations
import itertools
import logging
from collections.abc import Iterable
from typing import TYPE_CHECKING
import flask
from ordered_set import OrderedSet
from ._limits import ApplicationLimit, RuntimeLimit
from .constants import ExemptionScope
from .util import get_qualified_name
if TYPE_CHECKING:
from . import Limit
class LimitManager:
def __init__(
self,
application_limits: list[ApplicationLimit],
default_limits: list[Limit],
decorated_limits: dict[str, OrderedSet[Limit]],
blueprint_limits: dict[str, OrderedSet[Limit]],
route_exemptions: dict[str, ExemptionScope],
blueprint_exemptions: dict[str, ExemptionScope],
) -> None:
self._application_limits = application_limits
self._default_limits = default_limits
self._decorated_limits = decorated_limits
self._blueprint_limits = blueprint_limits
self._route_exemptions = route_exemptions
self._blueprint_exemptions = blueprint_exemptions
self._endpoint_hints: dict[str, OrderedSet[str]] = {}
self._logger = logging.getLogger("flask-limiter")
@property
def application_limits(self) -> list[RuntimeLimit]:
return list(itertools.chain(*self._application_limits))
@property
def default_limits(self) -> list[RuntimeLimit]:
return list(itertools.chain(*self._default_limits))
def set_application_limits(self, limits: list[ApplicationLimit]) -> None:
self._application_limits = limits
def set_default_limits(self, limits: list[Limit]) -> None:
self._default_limits = limits
def add_decorated_limit(self, route: str, limit: Limit | None, override: bool = False) -> None:
if limit:
if not override:
self._decorated_limits.setdefault(route, OrderedSet()).add(limit)
else:
self._decorated_limits[route] = OrderedSet([limit])
def add_blueprint_limit(self, blueprint: str, limit: Limit | None) -> None:
if limit:
self._blueprint_limits.setdefault(blueprint, OrderedSet()).add(limit)
def add_route_exemption(self, route: str, scope: ExemptionScope) -> None:
self._route_exemptions[route] = scope
def add_blueprint_exemption(self, blueprint: str, scope: ExemptionScope) -> None:
self._blueprint_exemptions[blueprint] = scope
def add_endpoint_hint(self, endpoint: str, callable: str) -> None:
self._endpoint_hints.setdefault(endpoint, OrderedSet()).add(callable)
def has_hints(self, endpoint: str) -> bool:
return bool(self._endpoint_hints.get(endpoint))
def resolve_limits(
self,
app: flask.Flask,
endpoint: str | None = None,
blueprint: str | None = None,
callable_name: str | None = None,
in_middleware: bool = False,
marked_for_limiting: bool = False,
) -> tuple[list[RuntimeLimit], ...]:
before_request_context = in_middleware and marked_for_limiting
decorated_limits = []
hinted_limits = []
if endpoint:
if not in_middleware:
if not callable_name:
view_func = app.view_functions.get(endpoint, None)
name = get_qualified_name(view_func) if view_func else ""
else:
name = callable_name
decorated_limits.extend(self.decorated_limits(name))
for hint in self._endpoint_hints.get(endpoint, OrderedSet()):
hinted_limits.extend(self.decorated_limits(hint))
if blueprint:
if not before_request_context and (
not decorated_limits
or all(not limit.override_defaults for limit in decorated_limits)
):
decorated_limits.extend(self.blueprint_limits(app, blueprint))
exemption_scope = self.exemption_scope(app, endpoint, blueprint)
all_limits = (
self.application_limits
if in_middleware and not (exemption_scope & ExemptionScope.APPLICATION)
else []
)
# all_limits += decorated_limits
explicit_limits_exempt = all(limit.method_exempt for limit in decorated_limits)
# all the decorated limits explicitly declared
# that they don't override the defaults - so, they should
# be included.
combined_defaults = all(not limit.override_defaults for limit in decorated_limits)
# previous requests to this endpoint have exercised decorated
# rate limits on callables that are not view functions. check
# if all of them declared that they don't override defaults
# and if so include the default limits.
hinted_limits_request_defaults = (
all(not limit.override_defaults for limit in hinted_limits) if hinted_limits else False
)
if (
(explicit_limits_exempt or combined_defaults)
and (not (before_request_context or exemption_scope & ExemptionScope.DEFAULT))
) or hinted_limits_request_defaults:
all_limits += self.default_limits
return all_limits, decorated_limits
def exemption_scope(
self, app: flask.Flask, endpoint: str | None, blueprint: str | None
) -> ExemptionScope:
view_func = app.view_functions.get(endpoint or "", None)
name = get_qualified_name(view_func) if view_func else ""
route_exemption_scope = self._route_exemptions.get(name, ExemptionScope.NONE)
blueprint_instance = app.blueprints.get(blueprint) if blueprint else None
if not blueprint_instance:
return route_exemption_scope
else:
assert blueprint
(
blueprint_exemption_scope,
ancestor_exemption_scopes,
) = self._blueprint_exemption_scope(app, blueprint)
if (
blueprint_exemption_scope & ~(ExemptionScope.DEFAULT | ExemptionScope.APPLICATION)
or ancestor_exemption_scopes
):
for exemption in ancestor_exemption_scopes.values():
blueprint_exemption_scope |= exemption
return route_exemption_scope | blueprint_exemption_scope
def decorated_limits(self, callable_name: str) -> list[RuntimeLimit]:
limits = []
if not self._route_exemptions.get(callable_name, ExemptionScope.NONE):
if callable_name in self._decorated_limits:
for group in self._decorated_limits[callable_name]:
try:
for limit in group:
limits.append(limit)
except ValueError as e:
self._logger.error(
f"failed to load ratelimit for function {callable_name}: {e}",
)
return limits
def blueprint_limits(self, app: flask.Flask, blueprint: str) -> list[RuntimeLimit]:
limits: list[RuntimeLimit] = []
blueprint_instance = app.blueprints.get(blueprint) if blueprint else None
if blueprint_instance:
blueprint_name = blueprint_instance.name
blueprint_ancestory = set(blueprint.split(".") if blueprint else [])
self_exemption, ancestor_exemptions = self._blueprint_exemption_scope(app, blueprint)
if not (self_exemption & ~(ExemptionScope.DEFAULT | ExemptionScope.APPLICATION)):
blueprint_self_limits = self._blueprint_limits.get(blueprint_name, OrderedSet())
blueprint_limits: Iterable[Limit] = (
itertools.chain(
*(
self._blueprint_limits.get(member, [])
for member in blueprint_ancestory.intersection(
self._blueprint_limits
).difference(ancestor_exemptions)
)
)
if not (
blueprint_self_limits
and all(limit.override_defaults for limit in blueprint_self_limits)
)
and not self._blueprint_exemptions.get(blueprint_name, ExemptionScope.NONE)
& ExemptionScope.ANCESTORS
else blueprint_self_limits
)
if blueprint_limits:
for limit_group in blueprint_limits:
try:
limits.extend(
[
RuntimeLimit(
limit.limit,
limit.key_func,
limit.scope,
limit.per_method,
limit.methods,
limit.error_message,
limit.exempt_when,
limit.override_defaults,
limit.deduct_when,
limit.on_breach,
limit.cost,
limit.shared,
)
for limit in limit_group
]
)
except ValueError as e:
self._logger.error(
f"failed to load ratelimit for blueprint {blueprint_name}: {e}",
)
return limits
def _blueprint_exemption_scope(
self, app: flask.Flask, blueprint_name: str
) -> tuple[ExemptionScope, dict[str, ExemptionScope]]:
name = app.blueprints[blueprint_name].name
exemption = self._blueprint_exemptions.get(name, ExemptionScope.NONE) & ~(
ExemptionScope.ANCESTORS
)
ancestory = set(blueprint_name.split("."))
ancestor_exemption = {
k for k, f in self._blueprint_exemptions.items() if f & ExemptionScope.DESCENDENTS
}.intersection(ancestory)
return exemption, {
k: self._blueprint_exemptions.get(k, ExemptionScope.NONE) for k in ancestor_exemption
}

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Callable, Generator, Iterable, Iterator, Sequence
from typing import (
ParamSpec,
TypeVar,
cast,
)
from typing_extensions import Self
R = TypeVar("R")
P = ParamSpec("P")
__all__ = [
"Callable",
"Generator",
"Iterable",
"Iterator",
"P",
"R",
"Sequence",
"Self",
"TypeVar",
"cast",
]

View File

@@ -0,0 +1,34 @@
# file generated by setuptools-scm
# don't change, don't track in version control
__all__ = [
"__version__",
"__version_tuple__",
"version",
"version_tuple",
"__commit_id__",
"commit_id",
]
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Tuple
from typing import Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
COMMIT_ID = Union[str, None]
else:
VERSION_TUPLE = object
COMMIT_ID = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version__ = version = '4.0.0'
__version_tuple__ = version_tuple = (4, 0, 0)
__commit_id__ = commit_id = None

View File

@@ -0,0 +1 @@
__version__: str

View File

@@ -0,0 +1,563 @@
from __future__ import annotations
import itertools
import time
from functools import partial
from typing import Any
from urllib.parse import urlparse
import click
from flask import Flask, current_app
from flask.cli import with_appcontext
from limits.strategies import RateLimiter
from rich.console import Console, group
from rich.live import Live
from rich.pretty import Pretty
from rich.prompt import Confirm
from rich.table import Table
from rich.theme import Theme
from rich.tree import Tree
from typing_extensions import TypedDict
from werkzeug.exceptions import MethodNotAllowed, NotFound
from werkzeug.routing import Rule
from ._extension import Limiter
from ._limits import RuntimeLimit
from ._typing import Callable, Generator, cast
from .constants import ConfigVars, ExemptionScope, HeaderNames
from .util import get_qualified_name
limiter_theme = Theme(
{
"success": "bold green",
"danger": "bold red",
"error": "bold red",
"blueprint": "bold red",
"default": "magenta",
"callable": "cyan",
"entity": "magenta",
"exempt": "bold red",
"route": "yellow",
"http": "bold green",
"option": "bold yellow",
}
)
def render_func(func: Any) -> str | Pretty:
if callable(func):
if func.__name__ == "<lambda>":
return f"[callable]<lambda>({func.__module__})[/callable]"
return f"[callable]{func.__module__}.{func.__name__}()[/callable]"
return Pretty(func)
def render_storage(ext: Limiter) -> Tree:
render = Tree(ext._storage_uri or "N/A")
if ext.storage:
render.add(f"[entity]{ext.storage.__class__.__name__}[/entity]")
render.add(f"[entity]{ext.storage.storage}[/entity]") # type: ignore
render.add(Pretty(ext._storage_options or {}))
health = ext.storage.check()
if health:
render.add("[success]OK[/success]")
else:
render.add("[error]Error[/error]")
return render
def render_strategy(strategy: RateLimiter) -> str:
return f"[entity]{strategy.__class__.__name__}[/entity]"
def render_limit_state(
limiter: Limiter, endpoint: str, limit: RuntimeLimit, key: str, method: str
) -> str:
args = [key, limit.scope_for(endpoint, method)]
if not limiter.storage or (limiter.storage and not limiter.storage.check()):
return ": [error]Storage not available[/error]"
test = limiter.limiter.test(limit.limit, *args)
stats = limiter.limiter.get_window_stats(limit.limit, *args)
if not test:
return f": [error]Fail[/error] ({stats[1]} out of {limit.limit.amount} remaining)"
else:
return f": [success]Pass[/success] ({stats[1]} out of {limit.limit.amount} remaining)"
def render_limit(limit: RuntimeLimit, simple: bool = True) -> str:
render = str(limit.limit)
if simple:
return render
options = []
if limit.deduct_when:
options.append(f"deduct_when: {render_func(limit.deduct_when)}")
if limit.exempt_when:
options.append(f"exempt_when: {render_func(limit.exempt_when)}")
if options:
render = f"{render} [option]{{{', '.join(options)}}}[/option]"
return render
def render_limits(
app: Flask,
limiter: Limiter,
limits: tuple[list[RuntimeLimit], ...],
endpoint: str | None = None,
blueprint: str | None = None,
rule: Rule | None = None,
exemption_scope: ExemptionScope = ExemptionScope.NONE,
test: str | None = None,
method: str = "GET",
label: str | None = "",
) -> Tree:
_label = None
if rule and endpoint:
_label = f"{endpoint}: {rule}"
label = _label or label or ""
renderable = Tree(label)
entries = []
for limit in limits[0] + limits[1]:
if endpoint:
view_func = app.view_functions.get(endpoint, None)
source = (
"blueprint"
if blueprint and limit in limiter.limit_manager.blueprint_limits(app, blueprint)
else (
"route"
if limit
in limiter.limit_manager.decorated_limits(
get_qualified_name(view_func) if view_func else ""
)
else "default"
)
)
else:
source = "default"
if limit.per_method and rule and rule.methods:
for method in rule.methods:
rendered = render_limit(limit, False)
entry = f"[{source}]{rendered} [http]({method})[/http][/{source}]"
if test:
entry += render_limit_state(limiter, endpoint or "", limit, test, method)
entries.append(entry)
else:
rendered = render_limit(limit, False)
entry = f"[{source}]{rendered}[/{source}]"
if test:
entry += render_limit_state(limiter, endpoint or "", limit, test, method)
entries.append(entry)
if not entries and exemption_scope:
renderable.add("[exempt]Exempt[/exempt]")
else:
[renderable.add(entry) for entry in entries]
return renderable
def get_filtered_endpoint(
app: Flask,
console: Console,
endpoint: str | None,
path: str | None,
method: str | None = None,
) -> str | None:
if not (endpoint or path):
return None
if endpoint:
if endpoint in current_app.view_functions:
return endpoint
else:
console.print(f"[red]Error: {endpoint} not found")
elif path:
adapter = app.url_map.bind("dev.null")
parsed = urlparse(path)
try:
filter_endpoint, _ = adapter.match(parsed.path, method=method, query_args=parsed.query)
return cast(str, filter_endpoint)
except NotFound:
console.print(f"[error]Error: {path} could not be matched to an endpoint[/error]")
except MethodNotAllowed:
assert method
console.print(
f"[error]Error: {method.upper()}: {path}"
" could not be matched to an endpoint[/error]"
)
raise SystemExit
@click.group(help="Flask-Limiter maintenance & utility commmands")
def cli() -> None:
pass
@cli.command(help="View the extension configuration")
@with_appcontext
def config() -> None:
with current_app.test_request_context():
console = Console(theme=limiter_theme)
limiters = list(current_app.extensions.get("limiter", set()))
limiter = limiters and list(limiters)[0]
if limiter:
extension_details = Table(title="Flask-Limiter Config")
extension_details.add_column("Notes")
extension_details.add_column("Configuration")
extension_details.add_column("Value")
extension_details.add_row("Enabled", ConfigVars.ENABLED, Pretty(limiter.enabled))
extension_details.add_row(
"Key Function", ConfigVars.KEY_FUNC, render_func(limiter._key_func)
)
extension_details.add_row(
"Key Prefix", ConfigVars.KEY_PREFIX, Pretty(limiter._key_prefix)
)
limiter_config = Tree(ConfigVars.STRATEGY)
limiter_config_values = Tree(render_strategy(limiter.limiter))
node = limiter_config.add(ConfigVars.STORAGE_URI)
node.add("Instance")
node.add("Backend")
limiter_config.add(ConfigVars.STORAGE_OPTIONS)
limiter_config.add("Status")
limiter_config_values.add(render_storage(limiter))
extension_details.add_row("Rate Limiting Config", limiter_config, limiter_config_values)
if limiter.limit_manager.application_limits:
extension_details.add_row(
"Application Limits",
ConfigVars.APPLICATION_LIMITS,
Pretty(
[render_limit(limit) for limit in limiter.limit_manager.application_limits]
),
)
extension_details.add_row(
None,
ConfigVars.APPLICATION_LIMITS_PER_METHOD,
Pretty(limiter._application_limits_per_method),
)
extension_details.add_row(
None,
ConfigVars.APPLICATION_LIMITS_EXEMPT_WHEN,
render_func(limiter._application_limits_exempt_when),
)
extension_details.add_row(
None,
ConfigVars.APPLICATION_LIMITS_DEDUCT_WHEN,
render_func(limiter._application_limits_deduct_when),
)
extension_details.add_row(
None,
ConfigVars.APPLICATION_LIMITS_COST,
Pretty(limiter._application_limits_cost),
)
else:
extension_details.add_row(
"ApplicationLimits Limits",
ConfigVars.APPLICATION_LIMITS,
Pretty([]),
)
if limiter.limit_manager.default_limits:
extension_details.add_row(
"Default Limits",
ConfigVars.DEFAULT_LIMITS,
Pretty([render_limit(limit) for limit in limiter.limit_manager.default_limits]),
)
extension_details.add_row(
None,
ConfigVars.DEFAULT_LIMITS_PER_METHOD,
Pretty(limiter._default_limits_per_method),
)
extension_details.add_row(
None,
ConfigVars.DEFAULT_LIMITS_EXEMPT_WHEN,
render_func(limiter._default_limits_exempt_when),
)
extension_details.add_row(
None,
ConfigVars.DEFAULT_LIMITS_DEDUCT_WHEN,
render_func(limiter._default_limits_deduct_when),
)
extension_details.add_row(
None,
ConfigVars.DEFAULT_LIMITS_COST,
render_func(limiter._default_limits_cost),
)
else:
extension_details.add_row("Default Limits", ConfigVars.DEFAULT_LIMITS, Pretty([]))
if limiter._meta_limits:
extension_details.add_row(
"Meta Limits",
ConfigVars.META_LIMITS,
Pretty(
[render_limit(limit) for limit in itertools.chain(*limiter._meta_limits)]
),
)
if limiter._headers_enabled:
header_configs = Tree(ConfigVars.HEADERS_ENABLED)
header_configs.add(ConfigVars.HEADER_RESET)
header_configs.add(ConfigVars.HEADER_REMAINING)
header_configs.add(ConfigVars.HEADER_RETRY_AFTER)
header_configs.add(ConfigVars.HEADER_RETRY_AFTER_VALUE)
header_values = Tree(Pretty(limiter._headers_enabled))
header_values.add(Pretty(limiter._header_mapping[HeaderNames.RESET]))
header_values.add(Pretty(limiter._header_mapping[HeaderNames.REMAINING]))
header_values.add(Pretty(limiter._header_mapping[HeaderNames.RETRY_AFTER]))
header_values.add(Pretty(limiter._retry_after))
extension_details.add_row(
"Header configuration",
header_configs,
header_values,
)
else:
extension_details.add_row(
"Header configuration", ConfigVars.HEADERS_ENABLED, Pretty(False)
)
extension_details.add_row(
"Fail on first breach",
ConfigVars.FAIL_ON_FIRST_BREACH,
Pretty(limiter._fail_on_first_breach),
)
extension_details.add_row(
"On breach callback",
ConfigVars.ON_BREACH,
render_func(limiter._on_breach),
)
console.print(extension_details)
else:
console.print(
f"No Flask-Limiter extension installed on {current_app}",
style="bold red",
)
@cli.command(help="Enumerate details about all routes with rate limits")
@click.option("--endpoint", default=None, help="Endpoint to filter by")
@click.option("--path", default=None, help="Path to filter by")
@click.option("--method", default=None, help="HTTP Method to filter by")
@click.option("--key", default=None, help="Test the limit")
@click.option("--watch/--no-watch", default=False, help="Create a live dashboard")
@with_appcontext
def limits(
endpoint: str | None = None,
path: str | None = None,
method: str = "GET",
key: str | None = None,
watch: bool = False,
) -> None:
with current_app.test_request_context():
limiters: set[Limiter] = current_app.extensions.get("limiter", set())
limiter: Limiter | None = list(limiters)[0] if limiters else None
console = Console(theme=limiter_theme)
if limiter:
manager = limiter.limit_manager
groups: dict[str, list[Callable[..., Tree]]] = {}
filter_endpoint = get_filtered_endpoint(current_app, console, endpoint, path, method)
for rule in sorted(
current_app.url_map.iter_rules(filter_endpoint), key=lambda r: str(r)
):
rule_endpoint = rule.endpoint
if rule_endpoint == "static":
continue
if len(rule_endpoint.split(".")) > 1:
bp_fullname = ".".join(rule_endpoint.split(".")[:-1])
groups.setdefault(bp_fullname, []).append(
partial(
render_limits,
current_app,
limiter,
manager.resolve_limits(current_app, rule_endpoint, bp_fullname),
rule_endpoint,
bp_fullname,
rule,
exemption_scope=manager.exemption_scope(
current_app, rule_endpoint, bp_fullname
),
method=method,
test=key,
)
)
else:
groups.setdefault("root", []).append(
partial(
render_limits,
current_app,
limiter,
manager.resolve_limits(current_app, rule_endpoint, ""),
rule_endpoint,
None,
rule,
exemption_scope=manager.exemption_scope(
current_app, rule_endpoint, None
),
method=method,
test=key,
)
)
@group()
def console_renderable() -> Generator: # type: ignore
if limiter and limiter.limit_manager.application_limits and not (endpoint or path):
yield render_limits(
current_app,
limiter,
(list(itertools.chain(*limiter._meta_limits)), []),
test=key,
method=method,
label="[gold3]Meta Limits[/gold3]",
)
yield render_limits(
current_app,
limiter,
(limiter.limit_manager.application_limits, []),
test=key,
method=method,
label="[gold3]Application Limits[/gold3]",
)
for name in groups:
if name == "root":
group_tree = Tree(f"[gold3]{current_app.name}[/gold3]")
else:
group_tree = Tree(f"[blue]{name}[/blue]")
[group_tree.add(renderable()) for renderable in groups[name]]
yield group_tree
if not watch:
console.print(console_renderable())
else: # noqa
with Live(
console_renderable(),
console=console,
refresh_per_second=0.4,
screen=True,
) as live:
while True:
try:
live.update(console_renderable())
time.sleep(0.4)
except KeyboardInterrupt:
break
else:
console.print(
f"No Flask-Limiter extension installed on {current_app}",
style="bold red",
)
@cli.command(help="Clear limits for a specific key")
@click.option("--endpoint", default=None, help="Endpoint to filter by")
@click.option("--path", default=None, help="Path to filter by")
@click.option("--method", default=None, help="HTTP Method to filter by")
@click.option("--key", default=None, required=True, help="Key to reset the limits for")
@click.option("-y", is_flag=True, help="Skip prompt for confirmation")
@with_appcontext
def clear(
key: str,
endpoint: str | None = None,
path: str | None = None,
method: str = "GET",
y: bool = False,
) -> None:
with current_app.test_request_context():
limiters = list(current_app.extensions.get("limiter", set()))
limiter: Limiter | None = limiters[0] if limiters else None
console = Console(theme=limiter_theme)
if limiter:
manager = limiter.limit_manager
filter_endpoint = get_filtered_endpoint(current_app, console, endpoint, path, method)
class Details(TypedDict):
rule: Rule
limits: tuple[list[RuntimeLimit], ...]
rule_limits: dict[str, Details] = {}
for rule in sorted(
current_app.url_map.iter_rules(filter_endpoint), key=lambda r: str(r)
):
rule_endpoint = rule.endpoint
if rule_endpoint == "static":
continue
if len(rule_endpoint.split(".")) > 1:
bp_fullname = ".".join(rule_endpoint.split(".")[:-1])
rule_limits[rule_endpoint] = Details(
rule=rule,
limits=manager.resolve_limits(current_app, rule_endpoint, bp_fullname),
)
else:
rule_limits[rule_endpoint] = Details(
rule=rule,
limits=manager.resolve_limits(current_app, rule_endpoint, ""),
)
application_limits = None
if not filter_endpoint:
application_limits = limiter.limit_manager.application_limits
if not y: # noqa
if application_limits:
console.print(
render_limits(
current_app,
limiter,
(application_limits, []),
label="Application Limits",
test=key,
)
)
for endpoint, details in rule_limits.items():
if details["limits"]:
console.print(
render_limits(
current_app,
limiter,
details["limits"],
endpoint,
rule=details["rule"],
test=key,
)
)
if y or Confirm.ask(f"Proceed with resetting limits for key: [danger]{key}[/danger]?"):
if application_limits:
node = Tree("Application Limits")
for limit in application_limits:
limiter.limiter.clear(
limit.limit,
key,
limit.scope_for("", method),
)
node.add(f"{render_limit(limit)}: [success]Cleared[/success]")
console.print(node)
for endpoint, details in rule_limits.items():
if details["limits"]:
node = Tree(endpoint)
default, decorated = details["limits"]
for limit in default + decorated:
if (
limit.per_method
and details["rule"]
and details["rule"].methods
and not method
):
for rule_method in details["rule"].methods:
limiter.limiter.clear(
limit.limit,
key,
limit.scope_for(endpoint, rule_method),
)
else:
limiter.limiter.clear(
limit.limit,
key,
limit.scope_for(endpoint, method),
)
node.add(f"{render_limit(limit)}: [success]Cleared[/success]")
console.print(node)
else:
console.print(
f"No Flask-Limiter extension installed on {current_app}",
style="bold red",
)
if __name__ == "__main__": # noqa
cli()

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import enum
class ConfigVars:
ENABLED = "RATELIMIT_ENABLED"
KEY_FUNC = "RATELIMIT_KEY_FUNC"
KEY_PREFIX = "RATELIMIT_KEY_PREFIX"
FAIL_ON_FIRST_BREACH = "RATELIMIT_FAIL_ON_FIRST_BREACH"
ON_BREACH = "RATELIMIT_ON_BREACH_CALLBACK"
SWALLOW_ERRORS = "RATELIMIT_SWALLOW_ERRORS"
APPLICATION_LIMITS = "RATELIMIT_APPLICATION"
APPLICATION_LIMITS_PER_METHOD = "RATELIMIT_APPLICATION_PER_METHOD"
APPLICATION_LIMITS_EXEMPT_WHEN = "RATELIMIT_APPLICATION_EXEMPT_WHEN"
APPLICATION_LIMITS_DEDUCT_WHEN = "RATELIMIT_APPLICATION_DEDUCT_WHEN"
APPLICATION_LIMITS_COST = "RATELIMIT_APPLICATION_COST"
DEFAULT_LIMITS = "RATELIMIT_DEFAULT"
DEFAULT_LIMITS_PER_METHOD = "RATELIMIT_DEFAULTS_PER_METHOD"
DEFAULT_LIMITS_EXEMPT_WHEN = "RATELIMIT_DEFAULTS_EXEMPT_WHEN"
DEFAULT_LIMITS_DEDUCT_WHEN = "RATELIMIT_DEFAULTS_DEDUCT_WHEN"
DEFAULT_LIMITS_COST = "RATELIMIT_DEFAULTS_COST"
REQUEST_IDENTIFIER = "RATELIMIT_REQUEST_IDENTIFIER"
STRATEGY = "RATELIMIT_STRATEGY"
STORAGE_URI = "RATELIMIT_STORAGE_URI"
STORAGE_OPTIONS = "RATELIMIT_STORAGE_OPTIONS"
HEADERS_ENABLED = "RATELIMIT_HEADERS_ENABLED"
HEADER_LIMIT = "RATELIMIT_HEADER_LIMIT"
HEADER_REMAINING = "RATELIMIT_HEADER_REMAINING"
HEADER_RESET = "RATELIMIT_HEADER_RESET"
HEADER_RETRY_AFTER = "RATELIMIT_HEADER_RETRY_AFTER"
HEADER_RETRY_AFTER_VALUE = "RATELIMIT_HEADER_RETRY_AFTER_VALUE"
IN_MEMORY_FALLBACK = "RATELIMIT_IN_MEMORY_FALLBACK"
IN_MEMORY_FALLBACK_ENABLED = "RATELIMIT_IN_MEMORY_FALLBACK_ENABLED"
META_LIMITS = "RATELIMIT_META"
ON_META_BREACH = "RATELIMIT_ON_META_BREACH_CALLBACK"
class HeaderNames(enum.Enum):
"""
Enumeration of supported rate limit related headers to
be used when configuring via :paramref:`~flask_limiter.Limiter.header_name_mapping`
"""
#: Timestamp at which this rate limit will be reset
RESET = "X-RateLimit-Reset"
#: Remaining number of requests within the current window
REMAINING = "X-RateLimit-Remaining"
#: Total number of allowed requests within a window
LIMIT = "X-RateLimit-Limit"
#: Number of seconds to retry after at
RETRY_AFTER = "Retry-After"
class ExemptionScope(enum.Flag):
"""
Flags used to configure the scope of exemption when used
in conjunction with :meth:`~flask_limiter.Limiter.exempt`.
"""
NONE = 0
#: Exempt from application wide "global" limits
APPLICATION = enum.auto()
#: Exempts from meta limits
META = enum.auto()
#: Exempt from default limits configured on the extension
DEFAULT = enum.auto()
#: Exempts any nested blueprints. See :ref:`recipes:nested blueprints`
DESCENDENTS = enum.auto()
#: Exempt from any rate limits inherited from ancestor blueprints.
#: See :ref:`recipes:nested blueprints`
ANCESTORS = enum.auto()
MAX_BACKEND_CHECKS = 5

View File

@@ -0,0 +1 @@
"""Contributed 'recipes'"""

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from flask import request
def get_remote_address_cloudflare() -> str:
"""
:return: the ip address for the current request from the CF-Connecting-IP header
(or 127.0.0.1 if none found)
"""
return request.headers["CF-Connecting-IP"] or "127.0.0.1"

View File

@@ -0,0 +1,29 @@
"""errors and exceptions."""
from __future__ import annotations
from flask.wrappers import Response
from werkzeug import exceptions
from ._limits import RuntimeLimit
class RateLimitExceeded(exceptions.TooManyRequests):
"""Exception raised when a rate limit is hit."""
def __init__(self, limit: RuntimeLimit, response: Response | None = None) -> None:
"""
:param limit: The actual rate limit that was hit. This is used to construct the default
response message
:param response: Optional pre constructed response. If provided it will be rendered by
flask instead of the default error response of :class:`~werkzeug.exceptions.HTTPException`
"""
self.limit = limit
self.response = response
if limit.error_message:
description = (
limit.error_message if not callable(limit.error_message) else limit.error_message()
)
else:
description = str(limit.limit)
super().__init__(description=description, response=response)

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from flask import request
def get_remote_address() -> str:
"""
:return: the ip address for the current request (or 127.0.0.1 if none found)
"""
return request.remote_addr or "127.0.0.1"
def get_qualified_name(callable: Callable[..., Any]) -> str:
"""
Generate the fully qualified name of a callable for use in storing mappings of decorated
functions to rate limits
The __qualname__ of the callable is appended in case there is a name clash in a module due to
locally scoped functions that are decorated.
TODO: Ideally __qualname__ should be enough, however view functions generated by class based
views do not update that and therefore would not be uniquely identifiable unless
__module__ & __name__ are inspected.
:meta private:
"""
return f"{callable.__module__}.{callable.__name__}.{callable.__qualname__}"