Added github integration
This commit is contained in:
132
app.py
132
app.py
@@ -4,11 +4,16 @@
|
|||||||
# Non-members are redirected to /join (VIP portal) after Discord login.
|
# Non-members are redirected to /join (VIP portal) after Discord login.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os, json, time, requests
|
import os, time, requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import json as _json
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from flask_wtf import CSRFProtect
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort
|
Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort
|
||||||
)
|
)
|
||||||
@@ -27,6 +32,13 @@ app = Flask(
|
|||||||
)
|
)
|
||||||
app.secret_key = os.environ.get("APP_SECRET_KEY", "dev")
|
app.secret_key = os.environ.get("APP_SECRET_KEY", "dev")
|
||||||
|
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
get_remote_address,
|
||||||
|
app=app,
|
||||||
|
)
|
||||||
|
|
||||||
# Branding
|
# Branding
|
||||||
BRAND = os.environ.get("SITE_BRAND", "BuffTEKS")
|
BRAND = os.environ.get("SITE_BRAND", "BuffTEKS")
|
||||||
TAGLINE = os.environ.get("SITE_TAGLINE", "Student Engineers. Real Projects. Community Impact.")
|
TAGLINE = os.environ.get("SITE_TAGLINE", "Student Engineers. Real Projects. Community Impact.")
|
||||||
@@ -42,6 +54,8 @@ DISCORD_CLIENT_ID = os.environ.get("DISCORD_CLIENT_ID", "")
|
|||||||
DISCORD_CLIENT_SECRET = os.environ.get("DISCORD_CLIENT_SECRET", "")
|
DISCORD_CLIENT_SECRET = os.environ.get("DISCORD_CLIENT_SECRET", "")
|
||||||
OAUTH_REDIRECT_URI = os.environ.get("OAUTH_REDIRECT_URI", "http://localhost:5000/auth/discord/callback")
|
OAUTH_REDIRECT_URI = os.environ.get("OAUTH_REDIRECT_URI", "http://localhost:5000/auth/discord/callback")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Roles
|
# Roles
|
||||||
ADMIN_ROLE_IDS = {r.strip() for r in os.environ.get("ADMIN_ROLE_IDS", "").split(",") if r.strip()}
|
ADMIN_ROLE_IDS = {r.strip() for r in os.environ.get("ADMIN_ROLE_IDS", "").split(",") if r.strip()}
|
||||||
MEMBER_ROLE_IDS = {r.strip() for r in os.environ.get("MEMBER_ROLE_IDS", "").split(",") if r.strip()}
|
MEMBER_ROLE_IDS = {r.strip() for r in os.environ.get("MEMBER_ROLE_IDS", "").split(",") if r.strip()}
|
||||||
@@ -55,6 +69,16 @@ app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL
|
|||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
|
||||||
|
def getenv(name: str, default: Optional[str] = None):
|
||||||
|
return os.environ.get(name, default)
|
||||||
|
|
||||||
|
# NOW load env-based configuration
|
||||||
|
REPO_EVENT_CHANNEL_MAP = _json.loads(getenv("REPO_EVENT_CHANNEL_MAP", "{}"))
|
||||||
|
GITHUB_WEBHOOK_SECRET = getenv("GITHUB_WEBHOOK_SECRET", "")
|
||||||
|
DEFAULT_DISCORD_WEBHOOK = getenv("DISCORD_WEBHOOK_URL", "")
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Models
|
# Models
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -267,6 +291,7 @@ SAFE_ENDPOINTS = {
|
|||||||
"join",
|
"join",
|
||||||
"join_thanks",
|
"join_thanks",
|
||||||
"favicon",
|
"favicon",
|
||||||
|
"github_webhook",
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -747,6 +772,109 @@ def init_db():
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/webhooks/github")
|
||||||
|
@csrf.exempt
|
||||||
|
@limiter.exempt
|
||||||
|
def github_webhook():
|
||||||
|
event = request.headers.get("X-GitHub-Event")
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Identify repo
|
||||||
|
repo = payload.get("repository", {}).get("full_name")
|
||||||
|
if not repo:
|
||||||
|
return jsonify({"ok": False, "error": "No repository info"}), 400
|
||||||
|
|
||||||
|
# Look up channel mapping for this repo
|
||||||
|
repo_cfg = REPO_EVENT_CHANNEL_MAP.get(repo, {})
|
||||||
|
|
||||||
|
# Look up webhook for this specific event
|
||||||
|
webhook = repo_cfg.get(event)
|
||||||
|
if not webhook:
|
||||||
|
# No configured channel for this event → ignore safely
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"note": f"No channel configured for event `{event}` on repo `{repo}`"
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
# Format message
|
||||||
|
message = format_github_event(event, payload)
|
||||||
|
|
||||||
|
# Send to Discord
|
||||||
|
discord_webhook_send(webhook, message)
|
||||||
|
|
||||||
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
|
||||||
|
def format_github_event(event: str, p: dict) -> str:
|
||||||
|
repo = p.get("repository", {}).get("full_name", "Unknown Repo")
|
||||||
|
|
||||||
|
if event == "push":
|
||||||
|
pusher = p.get("pusher", {}).get("name")
|
||||||
|
commits = p.get("commits", [])
|
||||||
|
commit_lines = "\n".join(
|
||||||
|
f"- `{c.get('id','')[:7]}` {c.get('message','').strip()} — {c.get('author',{}).get('name','')}"
|
||||||
|
for c in commits
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"📦 **Push to `{repo}`** by **{pusher}**\n"
|
||||||
|
f"{commit_lines or '(no commit messages)'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event == "issues":
|
||||||
|
action = p.get("action")
|
||||||
|
issue = p.get("issue", {})
|
||||||
|
return (
|
||||||
|
f"🐛 **Issue {action} — #{issue.get('number')}**\n"
|
||||||
|
f"**{issue.get('title')}**\n"
|
||||||
|
f"{issue.get('html_url')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event == "pull_request":
|
||||||
|
action = p.get("action")
|
||||||
|
pr = p.get("pull_request", {})
|
||||||
|
return (
|
||||||
|
f"🔀 **PR {action} — #{pr.get('number')}**\n"
|
||||||
|
f"**{pr.get('title')}**\n"
|
||||||
|
f"{pr.get('html_url')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event == "release":
|
||||||
|
r = p.get("release", {})
|
||||||
|
return (
|
||||||
|
f"🚀 **New Release `{r.get('tag_name')}`**\n"
|
||||||
|
f"**{r.get('name')}**\n"
|
||||||
|
f"{r.get('html_url')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return f"ℹ️ Event `{event}` received from `{repo}`"
|
||||||
|
|
||||||
|
|
||||||
|
def discord_webhook_send(url: str, content: str):
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
requests.post(url, json={"content": content[:1900]}, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
print("Discord webhook error:", e)
|
||||||
|
|
||||||
|
|
||||||
|
def send_discord(msg: str, repo: str):
|
||||||
|
# Decide channel (repo-specific or fallback)
|
||||||
|
webhook = REPO_CHANNEL_MAP.get(repo, DEFAULT_DISCORD_WEBHOOK)
|
||||||
|
if not webhook:
|
||||||
|
print(f"No webhook for repo {repo}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
requests.post(webhook, json={"content": msg})
|
||||||
|
except Exception as e:
|
||||||
|
print("Discord error:", e)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Templates (written at import-time so Gunicorn has them)
|
# Templates (written at import-time so Gunicorn has them)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
7
buffteks/bin/markdown-it
Executable file
7
buffteks/bin/markdown-it
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/var/www/buffteks/buffteks/bin/python3
|
||||||
|
import sys
|
||||||
|
from markdown_it.cli.parse import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if sys.argv[0].endswith('.exe'):
|
||||||
|
sys.argv[0] = sys.argv[0][:-4]
|
||||||
|
sys.exit(main())
|
||||||
7
buffteks/bin/pygmentize
Executable file
7
buffteks/bin/pygmentize
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/var/www/buffteks/buffteks/bin/python3
|
||||||
|
import sys
|
||||||
|
from pygments.cmdline import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if sys.argv[0].endswith('.exe'):
|
||||||
|
sys.argv[0] = sys.argv[0][:-4]
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 James C Sinclair
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
Metadata-Version: 2.1
|
||||||
|
Name: StrEnum
|
||||||
|
Version: 0.4.15
|
||||||
|
Summary: An Enum that inherits from str.
|
||||||
|
Home-page: https://github.com/irgeek/StrEnum
|
||||||
|
Author: James Sinclair
|
||||||
|
Author-email: james@nurfherder.com
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Programming Language :: Python :: 3 :: Only
|
||||||
|
Classifier: Programming Language :: Python :: 3.7
|
||||||
|
Classifier: Programming Language :: Python :: 3.8
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: License :: OSI Approved :: MIT License
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
License-File: LICENSE
|
||||||
|
Provides-Extra: docs
|
||||||
|
Requires-Dist: sphinx ; extra == 'docs'
|
||||||
|
Requires-Dist: sphinx-rtd-theme ; extra == 'docs'
|
||||||
|
Requires-Dist: myst-parser[linkify] ; extra == 'docs'
|
||||||
|
Provides-Extra: release
|
||||||
|
Requires-Dist: twine ; extra == 'release'
|
||||||
|
Provides-Extra: test
|
||||||
|
Requires-Dist: pytest ; extra == 'test'
|
||||||
|
Requires-Dist: pytest-black ; extra == 'test'
|
||||||
|
Requires-Dist: pytest-cov ; extra == 'test'
|
||||||
|
Requires-Dist: pytest-pylint ; extra == 'test'
|
||||||
|
Requires-Dist: pylint ; extra == 'test'
|
||||||
|
|
||||||
|
# StrEnum
|
||||||
|
|
||||||
|
[](https://github.com/irgeek/StrEnum/actions)
|
||||||
|
|
||||||
|
StrEnum is a Python `enum.Enum` that inherits from `str` to complement
|
||||||
|
`enum.IntEnum` in the standard library. Supports python 3.7+.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
You can use [pip](https://pip.pypa.io/en/stable/) to install.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install StrEnum
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import auto
|
||||||
|
from strenum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class HttpMethod(StrEnum):
|
||||||
|
GET = auto()
|
||||||
|
HEAD = auto()
|
||||||
|
POST = auto()
|
||||||
|
PUT = auto()
|
||||||
|
DELETE = auto()
|
||||||
|
CONNECT = auto()
|
||||||
|
OPTIONS = auto()
|
||||||
|
TRACE = auto()
|
||||||
|
PATCH = auto()
|
||||||
|
|
||||||
|
|
||||||
|
assert HttpMethod.GET == "GET"
|
||||||
|
|
||||||
|
# You can use StrEnum values just like strings:
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
req = urllib.request.Request('https://www.python.org/', method=HttpMethod.HEAD)
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
html = response.read()
|
||||||
|
|
||||||
|
assert len(html) == 0 # HEAD requests do not (usually) include a body
|
||||||
|
```
|
||||||
|
|
||||||
|
There are classes whose `auto()` value folds each member name to upper or lower
|
||||||
|
case:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import auto
|
||||||
|
from strenum import LowercaseStrEnum, UppercaseStrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(LowercaseStrEnum):
|
||||||
|
Head = auto()
|
||||||
|
Body = auto()
|
||||||
|
Div = auto()
|
||||||
|
|
||||||
|
|
||||||
|
assert Tag.Head == "head"
|
||||||
|
assert Tag.Body == "body"
|
||||||
|
assert Tag.Div == "div"
|
||||||
|
|
||||||
|
|
||||||
|
class HttpMethod(UppercaseStrEnum):
|
||||||
|
Get = auto()
|
||||||
|
Head = auto()
|
||||||
|
Post = auto()
|
||||||
|
|
||||||
|
|
||||||
|
assert HttpMethod.Get == "GET"
|
||||||
|
assert HttpMethod.Head == "HEAD"
|
||||||
|
assert HttpMethod.Post == "POST"
|
||||||
|
```
|
||||||
|
|
||||||
|
As well as classes whose `auto()` value converts each member name to camelCase,
|
||||||
|
PascalCase, kebab-case, snake_case and MACRO_CASE:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import auto
|
||||||
|
from strenum import CamelCaseStrEnum, PascalCaseStrEnum
|
||||||
|
from strenum import KebabCaseStrEnum, SnakeCaseStrEnum
|
||||||
|
from strenum import MacroCaseStrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class CamelTestEnum(CamelCaseStrEnum):
|
||||||
|
OneTwoThree = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class PascalTestEnum(PascalCaseStrEnum):
|
||||||
|
OneTwoThree = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class KebabTestEnum(KebabCaseStrEnum):
|
||||||
|
OneTwoThree = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class SnakeTestEnum(SnakeCaseStrEnum):
|
||||||
|
OneTwoThree = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class MacroTestEnum(MacroCaseStrEnum):
|
||||||
|
OneTwoThree = auto()
|
||||||
|
|
||||||
|
|
||||||
|
assert CamelTestEnum.OneTwoThree == "oneTwoThree"
|
||||||
|
assert PascalTestEnum.OneTwoThree == "OneTwoThree"
|
||||||
|
assert KebabTestEnum.OneTwoThree == "one-two-three"
|
||||||
|
assert SnakeTestEnum.OneTwoThree == "one_two_three"
|
||||||
|
assert MacroTestEnum.OneTwoThree == "ONE_TWO_THREE"
|
||||||
|
```
|
||||||
|
|
||||||
|
As with any Enum you can, of course, manually assign values.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from strenum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Shape(StrEnum):
|
||||||
|
CIRCLE = "Circle"
|
||||||
|
|
||||||
|
|
||||||
|
assert Shape.CIRCLE == "Circle"
|
||||||
|
```
|
||||||
|
|
||||||
|
Doing this with the case-changing classes, though, won't manipulate
|
||||||
|
values--whatever you assign is the value they end up with.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from strenum import KebabCaseStrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Shape(KebabCaseStrEnum):
|
||||||
|
CIRCLE = "Circle"
|
||||||
|
|
||||||
|
|
||||||
|
# This will raise an AssertionError because the value wasn't converted to kebab-case.
|
||||||
|
assert Shape.CIRCLE == "circle"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests are welcome. For major changes, please open an issue first to
|
||||||
|
discuss what you would like to change.
|
||||||
|
|
||||||
|
Please ensure tests pass before submitting a PR. This repository uses
|
||||||
|
[Black](https://black.readthedocs.io/en/stable/) and
|
||||||
|
[Pylint](https://www.pylint.org/) for consistency. Both are run automatically
|
||||||
|
as part of the test suite.
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
Tests can be run using `make`:
|
||||||
|
|
||||||
|
```
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a virutal environment, install the module and its test
|
||||||
|
dependencies and run the tests. Alternatively you can do the same thing
|
||||||
|
manually:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install .[test]
|
||||||
|
.venv/bin/pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](https://choosealicense.com/licenses/mit/)
|
||||||
|
|
||||||
|
**N.B. Starting with Python 3.11, `enum.StrEnum` is available in the standard
|
||||||
|
library. This implementation is _not_ a drop-in replacement for the standard
|
||||||
|
library implementation. Specifically, the Python devs have decided to case fold
|
||||||
|
name to lowercase by default when `auto()` is used which I think violates the
|
||||||
|
principle of least surprise.**
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
StrEnum-0.4.15.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
StrEnum-0.4.15.dist-info/LICENSE,sha256=vNcz0KRlIhYrldurYffNwcPjaGHfoSfWikQ1JA02rTY,1073
|
||||||
|
StrEnum-0.4.15.dist-info/METADATA,sha256=xLhPNsn2ieV9BR8L-FvFTMMhG6x99Rj536PM2hf1KII,5290
|
||||||
|
StrEnum-0.4.15.dist-info/RECORD,,
|
||||||
|
StrEnum-0.4.15.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
|
||||||
|
StrEnum-0.4.15.dist-info/top_level.txt,sha256=lsVIlgvvAAG9MBoLNwpPgG5wMT33blIUUoJo215Y-N0,8
|
||||||
|
strenum/__init__.py,sha256=oOsokqYYQVwTBbjrnXdR89RMcx8q6aGmWl--dlf2VCE,8530
|
||||||
|
strenum/__init__.pyi,sha256=-qJs9THlVGY3_o_bVeE7jUevjWteU_zOy7cZkp3MGzA,1415
|
||||||
|
strenum/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
strenum/__pycache__/_name_mangler.cpython-311.pyc,,
|
||||||
|
strenum/__pycache__/_version.cpython-311.pyc,,
|
||||||
|
strenum/__pycache__/mixins.cpython-311.pyc,,
|
||||||
|
strenum/_name_mangler.py,sha256=o11M5-bURW2RBvRTYXFQIPNeqLzburdoWLIqk8X3ydw,3397
|
||||||
|
strenum/_name_mangler.pyi,sha256=91p30d_kMAFsX5r9pqvSeordIf4VONDymQhcEvot2XA,551
|
||||||
|
strenum/_version.py,sha256=ylMBlzCLrUBhNvBnKtzTqGzDqHnEmoQkm3K6p5Gx5ms,498
|
||||||
|
strenum/mixins.py,sha256=BtOEx1hrAZ1YhDURDjLTrunKVneVtezSfkBq3sZXqxE,2042
|
||||||
|
strenum/mixins.pyi,sha256=lV6wiQAxZMvMSUUtnxzV1QSsyBDzgpobywiGCwRwm30,387
|
||||||
|
strenum/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: bdist_wheel (0.40.0)
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
strenum
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: Deprecated
|
||||||
|
Version: 1.3.1
|
||||||
|
Summary: Python @deprecated decorator to deprecate old python classes, functions or methods.
|
||||||
|
Home-page: https://github.com/laurent-laporte-pro/deprecated
|
||||||
|
Author: Laurent LAPORTE
|
||||||
|
Author-email: laurent.laporte.pro@gmail.com
|
||||||
|
License: MIT
|
||||||
|
Project-URL: Documentation, https://deprecated.readthedocs.io/en/latest/
|
||||||
|
Project-URL: Source, https://github.com/laurent-laporte-pro/deprecated
|
||||||
|
Project-URL: Bug Tracker, https://github.com/laurent-laporte-pro/deprecated/issues
|
||||||
|
Keywords: deprecate,deprecated,deprecation,warning,warn,decorator
|
||||||
|
Platform: any
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Environment :: Web Environment
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: License :: OSI Approved :: MIT License
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Programming Language :: Python :: 2
|
||||||
|
Classifier: Programming Language :: Python :: 2.7
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.4
|
||||||
|
Classifier: Programming Language :: Python :: 3.5
|
||||||
|
Classifier: Programming Language :: Python :: 3.6
|
||||||
|
Classifier: Programming Language :: Python :: 3.7
|
||||||
|
Classifier: Programming Language :: Python :: 3.8
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Programming Language :: Python :: 3.14
|
||||||
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||||
|
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
License-File: LICENSE.rst
|
||||||
|
Requires-Dist: wrapt<3,>=1.10
|
||||||
|
Requires-Dist: inspect2; python_version < "3"
|
||||||
|
Provides-Extra: dev
|
||||||
|
Requires-Dist: tox; extra == "dev"
|
||||||
|
Requires-Dist: PyTest; extra == "dev"
|
||||||
|
Requires-Dist: PyTest-Cov; extra == "dev"
|
||||||
|
Requires-Dist: bump2version<1; extra == "dev"
|
||||||
|
Requires-Dist: setuptools; python_version >= "3.12" and extra == "dev"
|
||||||
|
Dynamic: author
|
||||||
|
Dynamic: author-email
|
||||||
|
Dynamic: classifier
|
||||||
|
Dynamic: description
|
||||||
|
Dynamic: description-content-type
|
||||||
|
Dynamic: home-page
|
||||||
|
Dynamic: keywords
|
||||||
|
Dynamic: license
|
||||||
|
Dynamic: license-file
|
||||||
|
Dynamic: platform
|
||||||
|
Dynamic: project-url
|
||||||
|
Dynamic: provides-extra
|
||||||
|
Dynamic: requires-dist
|
||||||
|
Dynamic: requires-python
|
||||||
|
Dynamic: summary
|
||||||
|
|
||||||
|
|
||||||
|
Deprecated Library
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Deprecated is Easy to Use
|
||||||
|
`````````````````````````
|
||||||
|
|
||||||
|
If you need to mark a function or a method as deprecated,
|
||||||
|
you can use the ``@deprecated`` decorator:
|
||||||
|
|
||||||
|
Save in a hello.py:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from deprecated import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated(version='1.2.1', reason="You should use another function")
|
||||||
|
def some_old_function(x, y):
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
|
||||||
|
class SomeClass(object):
|
||||||
|
@deprecated(version='1.3.0', reason="This method is deprecated")
|
||||||
|
def some_old_method(self, x, y):
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
|
||||||
|
some_old_function(12, 34)
|
||||||
|
obj = SomeClass()
|
||||||
|
obj.some_old_method(5, 8)
|
||||||
|
|
||||||
|
|
||||||
|
And Easy to Setup
|
||||||
|
`````````````````
|
||||||
|
|
||||||
|
And run it:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
$ pip install Deprecated
|
||||||
|
$ python hello.py
|
||||||
|
hello.py:15: DeprecationWarning: Call to deprecated function (or staticmethod) some_old_function.
|
||||||
|
(You should use another function) -- Deprecated since version 1.2.0.
|
||||||
|
some_old_function(12, 34)
|
||||||
|
hello.py:17: DeprecationWarning: Call to deprecated method some_old_method.
|
||||||
|
(This method is deprecated) -- Deprecated since version 1.3.0.
|
||||||
|
obj.some_old_method(5, 8)
|
||||||
|
|
||||||
|
|
||||||
|
You can document your code
|
||||||
|
``````````````````````````
|
||||||
|
|
||||||
|
Have you ever wonder how to document that some functions, classes, methods, etc. are deprecated?
|
||||||
|
This is now possible with the integrated Sphinx directives:
|
||||||
|
|
||||||
|
For instance, in hello_sphinx.py:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from deprecated.sphinx import deprecated
|
||||||
|
from deprecated.sphinx import versionadded
|
||||||
|
from deprecated.sphinx import versionchanged
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version='1.0', reason="This function is new")
|
||||||
|
def function_one():
|
||||||
|
'''This is the function one'''
|
||||||
|
|
||||||
|
|
||||||
|
@versionchanged(version='1.0', reason="This function is modified")
|
||||||
|
def function_two():
|
||||||
|
'''This is the function two'''
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated(version='1.0', reason="This function will be removed soon")
|
||||||
|
def function_three():
|
||||||
|
'''This is the function three'''
|
||||||
|
|
||||||
|
|
||||||
|
function_one()
|
||||||
|
function_two()
|
||||||
|
function_three() # warns
|
||||||
|
|
||||||
|
help(function_one)
|
||||||
|
help(function_two)
|
||||||
|
help(function_three)
|
||||||
|
|
||||||
|
|
||||||
|
The result it immediate
|
||||||
|
```````````````````````
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
$ python hello_sphinx.py
|
||||||
|
|
||||||
|
hello_sphinx.py:23: DeprecationWarning: Call to deprecated function (or staticmethod) function_three.
|
||||||
|
(This function will be removed soon) -- Deprecated since version 1.0.
|
||||||
|
function_three() # warns
|
||||||
|
|
||||||
|
Help on function function_one in module __main__:
|
||||||
|
|
||||||
|
function_one()
|
||||||
|
This is the function one
|
||||||
|
|
||||||
|
.. versionadded:: 1.0
|
||||||
|
This function is new
|
||||||
|
|
||||||
|
Help on function function_two in module __main__:
|
||||||
|
|
||||||
|
function_two()
|
||||||
|
This is the function two
|
||||||
|
|
||||||
|
.. versionchanged:: 1.0
|
||||||
|
This function is modified
|
||||||
|
|
||||||
|
Help on function function_three in module __main__:
|
||||||
|
|
||||||
|
function_three()
|
||||||
|
This is the function three
|
||||||
|
|
||||||
|
.. deprecated:: 1.0
|
||||||
|
This function will be removed soon
|
||||||
|
|
||||||
|
|
||||||
|
Links
|
||||||
|
`````
|
||||||
|
|
||||||
|
* `Python package index (PyPi) <https://pypi.org/project/Deprecated/>`_
|
||||||
|
* `GitHub website <https://github.com/laurent-laporte-pro/deprecated>`_
|
||||||
|
* `Read The Docs <https://readthedocs.org/projects/deprecated>`_
|
||||||
|
* `EBook on Lulu.com <http://www.lulu.com/commerce/index.php?fBuyContent=21305117>`_
|
||||||
|
* `StackOverFlow Q&A <https://stackoverflow.com/a/40301488/1513933>`_
|
||||||
|
* `Development version
|
||||||
|
<https://github.com/laurent-laporte-pro/deprecated/zipball/master#egg=Deprecated-dev>`_
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
deprecated-1.3.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
deprecated-1.3.1.dist-info/METADATA,sha256=QJdnrCOHjBxpSIjOSsxD2razfvorQEmVZ_tcfZ3OWFI,5894
|
||||||
|
deprecated-1.3.1.dist-info/RECORD,,
|
||||||
|
deprecated-1.3.1.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
||||||
|
deprecated-1.3.1.dist-info/licenses/LICENSE.rst,sha256=HoPt0VvkGbXVveNy4yXlJ_9PmRX1SOfHUxS0H2aZ6Dw,1081
|
||||||
|
deprecated-1.3.1.dist-info/top_level.txt,sha256=nHbOYawKPQQE5lQl-toUB1JBRJjUyn_m_Mb8RVJ0RjA,11
|
||||||
|
deprecated/__init__.py,sha256=owN3nj3UYte9J327NJNf_hNQTJovl813H_Q7YKhFX9M,398
|
||||||
|
deprecated/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
deprecated/__pycache__/classic.cpython-311.pyc,,
|
||||||
|
deprecated/__pycache__/params.cpython-311.pyc,,
|
||||||
|
deprecated/__pycache__/sphinx.cpython-311.pyc,,
|
||||||
|
deprecated/classic.py,sha256=vWW-8nVvEejx4P9sf75vleE9JWbETaha_Sa-Bf9Beyo,10649
|
||||||
|
deprecated/params.py,sha256=_bWRXLZGi2qF-EmZfgWjGe2G15WZabhOH_9Ntvt42cc,2870
|
||||||
|
deprecated/sphinx.py,sha256=cOKnXbDyFAwDr5O7HBEpgQrx-J-qfp57sfdK_LabDxs,11109
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: setuptools (80.9.0)
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py2-none-any
|
||||||
|
Tag: py3-none-any
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2017 Laurent LAPORTE
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
deprecated
|
||||||
16
buffteks/lib/python3.11/site-packages/deprecated/__init__.py
Normal file
16
buffteks/lib/python3.11/site-packages/deprecated/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Deprecated Library
|
||||||
|
==================
|
||||||
|
|
||||||
|
Python ``@deprecated`` decorator to deprecate old python classes, functions or methods.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.3.1"
|
||||||
|
__author__ = u"Laurent LAPORTE <laurent.laporte.pro@gmail.com>"
|
||||||
|
__date__ = "2025-10-30"
|
||||||
|
__credits__ = "(c) Laurent LAPORTE"
|
||||||
|
|
||||||
|
from deprecated.classic import deprecated
|
||||||
|
from deprecated.params import deprecated_params
|
||||||
301
buffteks/lib/python3.11/site-packages/deprecated/classic.py
Normal file
301
buffteks/lib/python3.11/site-packages/deprecated/classic.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Classic deprecation warning
|
||||||
|
===========================
|
||||||
|
|
||||||
|
Classic ``@deprecated`` decorator to deprecate old python classes, functions or methods.
|
||||||
|
|
||||||
|
.. _The Warnings Filter: https://docs.python.org/3/library/warnings.html#the-warnings-filter
|
||||||
|
"""
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
import platform
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import wrapt
|
||||||
|
|
||||||
|
try:
|
||||||
|
# If the C extension for wrapt was compiled and wrapt/_wrappers.pyd exists, then the
|
||||||
|
# stack level that should be passed to warnings.warn should be 2. However, if using
|
||||||
|
# a pure python wrapt, an extra stacklevel is required.
|
||||||
|
import wrapt._wrappers
|
||||||
|
|
||||||
|
_routine_stacklevel = 2
|
||||||
|
_class_stacklevel = 2
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_routine_stacklevel = 3
|
||||||
|
if platform.python_implementation() == "PyPy":
|
||||||
|
_class_stacklevel = 2
|
||||||
|
else:
|
||||||
|
_class_stacklevel = 3
|
||||||
|
|
||||||
|
string_types = (type(b''), type(u''))
|
||||||
|
|
||||||
|
|
||||||
|
class ClassicAdapter(wrapt.AdapterFactory):
|
||||||
|
"""
|
||||||
|
Classic adapter -- *for advanced usage only*
|
||||||
|
|
||||||
|
This adapter is used to get the deprecation message according to the wrapped object type:
|
||||||
|
class, function, standard method, static method, or class method.
|
||||||
|
|
||||||
|
This is the base class of the :class:`~deprecated.sphinx.SphinxAdapter` class
|
||||||
|
which is used to update the wrapped object docstring.
|
||||||
|
|
||||||
|
You can also inherit this class to change the deprecation message.
|
||||||
|
|
||||||
|
In the following example, we change the message into "The ... is deprecated.":
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from deprecated.classic import ClassicAdapter
|
||||||
|
from deprecated.classic import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
class MyClassicAdapter(ClassicAdapter):
|
||||||
|
def get_deprecated_msg(self, wrapped, instance):
|
||||||
|
if instance is None:
|
||||||
|
if inspect.isclass(wrapped):
|
||||||
|
fmt = "The class {name} is deprecated."
|
||||||
|
else:
|
||||||
|
fmt = "The function {name} is deprecated."
|
||||||
|
else:
|
||||||
|
if inspect.isclass(instance):
|
||||||
|
fmt = "The class method {name} is deprecated."
|
||||||
|
else:
|
||||||
|
fmt = "The method {name} is deprecated."
|
||||||
|
if self.reason:
|
||||||
|
fmt += " ({reason})"
|
||||||
|
if self.version:
|
||||||
|
fmt += " -- Deprecated since version {version}."
|
||||||
|
return fmt.format(name=wrapped.__name__,
|
||||||
|
reason=self.reason or "",
|
||||||
|
version=self.version or "")
|
||||||
|
|
||||||
|
Then, you can use your ``MyClassicAdapter`` class like this in your source code:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
@deprecated(reason="use another function", adapter_cls=MyClassicAdapter)
|
||||||
|
def some_old_function(x, y):
|
||||||
|
return x + y
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reason="", version="", action=None, category=DeprecationWarning, extra_stacklevel=0):
|
||||||
|
"""
|
||||||
|
Construct a wrapper adapter.
|
||||||
|
|
||||||
|
:type reason: str
|
||||||
|
:param reason:
|
||||||
|
Reason message which documents the deprecation in your library (can be omitted).
|
||||||
|
|
||||||
|
:type version: str
|
||||||
|
:param version:
|
||||||
|
Version of your project which deprecates this feature.
|
||||||
|
If you follow the `Semantic Versioning <https://semver.org/>`_,
|
||||||
|
the version number has the format "MAJOR.MINOR.PATCH".
|
||||||
|
|
||||||
|
:type action: Literal["default", "error", "ignore", "always", "module", "once"]
|
||||||
|
:param action:
|
||||||
|
A warning filter used to activate or not the deprecation warning.
|
||||||
|
Can be one of "error", "ignore", "always", "default", "module", or "once".
|
||||||
|
If ``None`` or empty, the global filtering mechanism is used.
|
||||||
|
See: `The Warnings Filter`_ in the Python documentation.
|
||||||
|
|
||||||
|
:type category: Type[Warning]
|
||||||
|
:param category:
|
||||||
|
The warning category to use for the deprecation warning.
|
||||||
|
By default, the category class is :class:`~DeprecationWarning`,
|
||||||
|
you can inherit this class to define your own deprecation warning category.
|
||||||
|
|
||||||
|
:type extra_stacklevel: int
|
||||||
|
:param extra_stacklevel:
|
||||||
|
Number of additional stack levels to consider instrumentation rather than user code.
|
||||||
|
With the default value of 0, the warning refers to where the class was instantiated
|
||||||
|
or the function was called.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2.15
|
||||||
|
Add the *extra_stacklevel* parameter.
|
||||||
|
"""
|
||||||
|
self.reason = reason or ""
|
||||||
|
self.version = version or ""
|
||||||
|
self.action = action
|
||||||
|
self.category = category
|
||||||
|
self.extra_stacklevel = extra_stacklevel
|
||||||
|
super(ClassicAdapter, self).__init__()
|
||||||
|
|
||||||
|
def get_deprecated_msg(self, wrapped, instance):
|
||||||
|
"""
|
||||||
|
Get the deprecation warning message for the user.
|
||||||
|
|
||||||
|
:param wrapped: Wrapped class or function.
|
||||||
|
|
||||||
|
:param instance: The object to which the wrapped function was bound when it was called.
|
||||||
|
|
||||||
|
:return: The warning message.
|
||||||
|
"""
|
||||||
|
if instance is None:
|
||||||
|
if inspect.isclass(wrapped):
|
||||||
|
fmt = "Call to deprecated class {name}."
|
||||||
|
else:
|
||||||
|
fmt = "Call to deprecated function (or staticmethod) {name}."
|
||||||
|
else:
|
||||||
|
if inspect.isclass(instance):
|
||||||
|
fmt = "Call to deprecated class method {name}."
|
||||||
|
else:
|
||||||
|
fmt = "Call to deprecated method {name}."
|
||||||
|
if self.reason:
|
||||||
|
fmt += " ({reason})"
|
||||||
|
if self.version:
|
||||||
|
fmt += " -- Deprecated since version {version}."
|
||||||
|
return fmt.format(name=wrapped.__name__, reason=self.reason or "", version=self.version or "")
|
||||||
|
|
||||||
|
def __call__(self, wrapped):
|
||||||
|
"""
|
||||||
|
Decorate your class or function.
|
||||||
|
|
||||||
|
:param wrapped: Wrapped class or function.
|
||||||
|
|
||||||
|
:return: the decorated class or function.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2.4
|
||||||
|
Don't pass arguments to :meth:`object.__new__` (other than *cls*).
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2.8
|
||||||
|
The warning filter is not set if the *action* parameter is ``None`` or empty.
|
||||||
|
"""
|
||||||
|
if inspect.isclass(wrapped):
|
||||||
|
old_new1 = wrapped.__new__
|
||||||
|
|
||||||
|
def wrapped_cls(cls, *args, **kwargs):
|
||||||
|
msg = self.get_deprecated_msg(wrapped, None)
|
||||||
|
stacklevel = _class_stacklevel + self.extra_stacklevel
|
||||||
|
if self.action:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter(self.action, self.category)
|
||||||
|
warnings.warn(msg, category=self.category, stacklevel=stacklevel)
|
||||||
|
else:
|
||||||
|
warnings.warn(msg, category=self.category, stacklevel=stacklevel)
|
||||||
|
if old_new1 is object.__new__:
|
||||||
|
return old_new1(cls)
|
||||||
|
# actually, we don't know the real signature of *old_new1*
|
||||||
|
return old_new1(cls, *args, **kwargs)
|
||||||
|
|
||||||
|
wrapped.__new__ = staticmethod(wrapped_cls)
|
||||||
|
|
||||||
|
elif inspect.isroutine(wrapped):
|
||||||
|
@wrapt.decorator
|
||||||
|
def wrapper_function(wrapped_, instance_, args_, kwargs_):
|
||||||
|
msg = self.get_deprecated_msg(wrapped_, instance_)
|
||||||
|
stacklevel = _routine_stacklevel + self.extra_stacklevel
|
||||||
|
if self.action:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter(self.action, self.category)
|
||||||
|
warnings.warn(msg, category=self.category, stacklevel=stacklevel)
|
||||||
|
else:
|
||||||
|
warnings.warn(msg, category=self.category, stacklevel=stacklevel)
|
||||||
|
return wrapped_(*args_, **kwargs_)
|
||||||
|
|
||||||
|
return wrapper_function(wrapped)
|
||||||
|
|
||||||
|
else: # pragma: no cover
|
||||||
|
raise TypeError(repr(type(wrapped)))
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def deprecated(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
This is a decorator which can be used to mark functions
|
||||||
|
as deprecated. It will result in a warning being emitted
|
||||||
|
when the function is used.
|
||||||
|
|
||||||
|
**Classic usage:**
|
||||||
|
|
||||||
|
To use this, decorate your deprecated function with **@deprecated** decorator:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from deprecated import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def some_old_function(x, y):
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
You can also decorate a class or a method:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from deprecated import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
class SomeClass(object):
|
||||||
|
@deprecated
|
||||||
|
def some_old_method(self, x, y):
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
class SomeOldClass(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
You can give a *reason* message to help the developer to choose another function/class,
|
||||||
|
and a *version* number to specify the starting version number of the deprecation.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from deprecated import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated(reason="use another function", version='1.2.0')
|
||||||
|
def some_old_function(x, y):
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
The *category* keyword argument allow you to specify the deprecation warning class of your choice.
|
||||||
|
By default, :exc:`DeprecationWarning` is used, but you can choose :exc:`FutureWarning`,
|
||||||
|
:exc:`PendingDeprecationWarning` or a custom subclass.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from deprecated import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated(category=PendingDeprecationWarning)
|
||||||
|
def some_old_function(x, y):
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
The *action* keyword argument allow you to locally change the warning filtering.
|
||||||
|
*action* can be one of "error", "ignore", "always", "default", "module", or "once".
|
||||||
|
If ``None``, empty or missing, the global filtering mechanism is used.
|
||||||
|
See: `The Warnings Filter`_ in the Python documentation.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from deprecated import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated(action="error")
|
||||||
|
def some_old_function(x, y):
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
The *extra_stacklevel* keyword argument allows you to specify additional stack levels
|
||||||
|
to consider instrumentation rather than user code. With the default value of 0, the
|
||||||
|
warning refers to where the class was instantiated or the function was called.
|
||||||
|
"""
|
||||||
|
if args and isinstance(args[0], string_types):
|
||||||
|
kwargs['reason'] = args[0]
|
||||||
|
args = args[1:]
|
||||||
|
|
||||||
|
if args and not callable(args[0]):
|
||||||
|
raise TypeError(repr(type(args[0])))
|
||||||
|
|
||||||
|
if args:
|
||||||
|
adapter_cls = kwargs.pop('adapter_cls', ClassicAdapter)
|
||||||
|
adapter = adapter_cls(**kwargs)
|
||||||
|
wrapped = args[0]
|
||||||
|
return adapter(wrapped)
|
||||||
|
|
||||||
|
return functools.partial(deprecated, **kwargs)
|
||||||
79
buffteks/lib/python3.11/site-packages/deprecated/params.py
Normal file
79
buffteks/lib/python3.11/site-packages/deprecated/params.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
"""
|
||||||
|
Parameters deprecation
|
||||||
|
======================
|
||||||
|
|
||||||
|
.. _Tantale's Blog: https://tantale.github.io/
|
||||||
|
.. _Deprecated Parameters: https://tantale.github.io/articles/deprecated_params/
|
||||||
|
|
||||||
|
This module introduces a :class:`deprecated_params` decorator to specify that one (or more)
|
||||||
|
parameter(s) are deprecated: when the user executes a function with a deprecated parameter,
|
||||||
|
he will see a warning message in the console.
|
||||||
|
|
||||||
|
The decorator is customizable, the user can specify the deprecated parameter names
|
||||||
|
and associate to each of them a message providing the reason of the deprecation.
|
||||||
|
As with the :func:`~deprecated.classic.deprecated` decorator, the user can specify
|
||||||
|
a version number (using the *version* parameter) and also define the warning message category
|
||||||
|
(a subclass of :class:`Warning`) and when to display the messages (using the *action* parameter).
|
||||||
|
|
||||||
|
The complete study concerning the implementation of this decorator is available on the `Tantale's blog`_,
|
||||||
|
on the `Deprecated Parameters`_ page.
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import functools
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
try:
|
||||||
|
# noinspection PyPackageRequirements
|
||||||
|
import inspect2 as inspect
|
||||||
|
except ImportError:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
|
||||||
|
class DeprecatedParams(object):
|
||||||
|
"""
|
||||||
|
Decorator used to decorate a function which at least one
|
||||||
|
of the parameters is deprecated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, param, reason="", category=DeprecationWarning):
|
||||||
|
self.messages = {} # type: dict[str, str]
|
||||||
|
self.category = category
|
||||||
|
self.populate_messages(param, reason=reason)
|
||||||
|
|
||||||
|
def populate_messages(self, param, reason=""):
|
||||||
|
if isinstance(param, dict):
|
||||||
|
self.messages.update(param)
|
||||||
|
elif isinstance(param, str):
|
||||||
|
fmt = "'{param}' parameter is deprecated"
|
||||||
|
reason = reason or fmt.format(param=param)
|
||||||
|
self.messages[param] = reason
|
||||||
|
else:
|
||||||
|
raise TypeError(param)
|
||||||
|
|
||||||
|
def check_params(self, signature, *args, **kwargs):
|
||||||
|
binding = signature.bind(*args, **kwargs)
|
||||||
|
bound = collections.OrderedDict(binding.arguments, **binding.kwargs)
|
||||||
|
return [param for param in bound if param in self.messages]
|
||||||
|
|
||||||
|
def warn_messages(self, messages):
|
||||||
|
# type: (list[str]) -> None
|
||||||
|
for message in messages:
|
||||||
|
warnings.warn(message, category=self.category, stacklevel=3)
|
||||||
|
|
||||||
|
def __call__(self, f):
|
||||||
|
# type: (callable) -> callable
|
||||||
|
signature = inspect.signature(f)
|
||||||
|
|
||||||
|
@functools.wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
invalid_params = self.check_params(signature, *args, **kwargs)
|
||||||
|
self.warn_messages([self.messages[param] for param in invalid_params])
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
#: Decorator used to decorate a function which at least one
|
||||||
|
#: of the parameters is deprecated.
|
||||||
|
deprecated_params = DeprecatedParams
|
||||||
281
buffteks/lib/python3.11/site-packages/deprecated/sphinx.py
Normal file
281
buffteks/lib/python3.11/site-packages/deprecated/sphinx.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
"""
|
||||||
|
Sphinx directive integration
|
||||||
|
============================
|
||||||
|
|
||||||
|
We usually need to document the life-cycle of functions and classes:
|
||||||
|
when they are created, modified or deprecated.
|
||||||
|
|
||||||
|
To do that, `Sphinx <http://www.sphinx-doc.org>`_ has a set
|
||||||
|
of `Paragraph-level markups <http://www.sphinx-doc.org/en/stable/markup/para.html>`_:
|
||||||
|
|
||||||
|
- ``versionadded``: to document the version of the project which added the described feature to the library,
|
||||||
|
- ``versionchanged``: to document changes of a feature,
|
||||||
|
- ``deprecated``: to document a deprecated feature.
|
||||||
|
|
||||||
|
The purpose of this module is to defined decorators which adds this Sphinx directives
|
||||||
|
to the docstring of your function and classes.
|
||||||
|
|
||||||
|
Of course, the ``@deprecated`` decorator will emit a deprecation warning
|
||||||
|
when the function/method is called or the class is constructed.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from deprecated.classic import ClassicAdapter
|
||||||
|
from deprecated.classic import deprecated as _classic_deprecated
|
||||||
|
|
||||||
|
|
||||||
|
class SphinxAdapter(ClassicAdapter):
|
||||||
|
"""
|
||||||
|
Sphinx adapter -- *for advanced usage only*
|
||||||
|
|
||||||
|
This adapter override the :class:`~deprecated.classic.ClassicAdapter`
|
||||||
|
in order to add the Sphinx directives to the end of the function/class docstring.
|
||||||
|
Such a directive is a `Paragraph-level markup <http://www.sphinx-doc.org/en/stable/markup/para.html>`_
|
||||||
|
|
||||||
|
- The directive can be one of "versionadded", "versionchanged" or "deprecated".
|
||||||
|
- The version number is added if provided.
|
||||||
|
- The reason message is obviously added in the directive block if not empty.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
directive,
|
||||||
|
reason="",
|
||||||
|
version="",
|
||||||
|
action=None,
|
||||||
|
category=DeprecationWarning,
|
||||||
|
extra_stacklevel=0,
|
||||||
|
line_length=70,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Construct a wrapper adapter.
|
||||||
|
|
||||||
|
:type directive: str
|
||||||
|
:param directive:
|
||||||
|
Sphinx directive: can be one of "versionadded", "versionchanged" or "deprecated".
|
||||||
|
|
||||||
|
:type reason: str
|
||||||
|
:param reason:
|
||||||
|
Reason message which documents the deprecation in your library (can be omitted).
|
||||||
|
|
||||||
|
:type version: str
|
||||||
|
:param version:
|
||||||
|
Version of your project which deprecates this feature.
|
||||||
|
If you follow the `Semantic Versioning <https://semver.org/>`_,
|
||||||
|
the version number has the format "MAJOR.MINOR.PATCH".
|
||||||
|
|
||||||
|
:type action: Literal["default", "error", "ignore", "always", "module", "once"]
|
||||||
|
:param action:
|
||||||
|
A warning filter used to activate or not the deprecation warning.
|
||||||
|
Can be one of "error", "ignore", "always", "default", "module", or "once".
|
||||||
|
If ``None`` or empty, the global filtering mechanism is used.
|
||||||
|
See: `The Warnings Filter`_ in the Python documentation.
|
||||||
|
|
||||||
|
:type category: Type[Warning]
|
||||||
|
:param category:
|
||||||
|
The warning category to use for the deprecation warning.
|
||||||
|
By default, the category class is :class:`~DeprecationWarning`,
|
||||||
|
you can inherit this class to define your own deprecation warning category.
|
||||||
|
|
||||||
|
:type extra_stacklevel: int
|
||||||
|
:param extra_stacklevel:
|
||||||
|
Number of additional stack levels to consider instrumentation rather than user code.
|
||||||
|
With the default value of 0, the warning refers to where the class was instantiated
|
||||||
|
or the function was called.
|
||||||
|
|
||||||
|
:type line_length: int
|
||||||
|
:param line_length:
|
||||||
|
Max line length of the directive text. If non nul, a long text is wrapped in several lines.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2.15
|
||||||
|
Add the *extra_stacklevel* parameter.
|
||||||
|
"""
|
||||||
|
if not version:
|
||||||
|
# https://github.com/laurent-laporte-pro/deprecated/issues/40
|
||||||
|
raise ValueError("'version' argument is required in Sphinx directives")
|
||||||
|
self.directive = directive
|
||||||
|
self.line_length = line_length
|
||||||
|
super(SphinxAdapter, self).__init__(
|
||||||
|
reason=reason, version=version, action=action, category=category, extra_stacklevel=extra_stacklevel
|
||||||
|
)
|
||||||
|
|
||||||
|
def __call__(self, wrapped):
|
||||||
|
"""
|
||||||
|
Add the Sphinx directive to your class or function.
|
||||||
|
|
||||||
|
:param wrapped: Wrapped class or function.
|
||||||
|
|
||||||
|
:return: the decorated class or function.
|
||||||
|
"""
|
||||||
|
# -- build the directive division
|
||||||
|
fmt = ".. {directive}:: {version}" if self.version else ".. {directive}::"
|
||||||
|
div_lines = [fmt.format(directive=self.directive, version=self.version)]
|
||||||
|
width = self.line_length - 3 if self.line_length > 3 else 2**16
|
||||||
|
reason = textwrap.dedent(self.reason).strip()
|
||||||
|
for paragraph in reason.splitlines():
|
||||||
|
if paragraph:
|
||||||
|
div_lines.extend(
|
||||||
|
textwrap.fill(
|
||||||
|
paragraph,
|
||||||
|
width=width,
|
||||||
|
initial_indent=" ",
|
||||||
|
subsequent_indent=" ",
|
||||||
|
).splitlines()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
div_lines.append("")
|
||||||
|
|
||||||
|
# -- get the docstring, normalize the trailing newlines
|
||||||
|
# keep a consistent behaviour if the docstring starts with newline or directly on the first one
|
||||||
|
docstring = wrapped.__doc__ or ""
|
||||||
|
lines = docstring.splitlines(True) or [""]
|
||||||
|
docstring = textwrap.dedent("".join(lines[1:])) if len(lines) > 1 else ""
|
||||||
|
docstring = lines[0] + docstring
|
||||||
|
if docstring:
|
||||||
|
# An empty line must separate the original docstring and the directive.
|
||||||
|
docstring = re.sub(r"\n+$", "", docstring, flags=re.DOTALL) + "\n\n"
|
||||||
|
else:
|
||||||
|
# Avoid "Explicit markup ends without a blank line" when the decorated function has no docstring
|
||||||
|
docstring = "\n"
|
||||||
|
|
||||||
|
# -- append the directive division to the docstring
|
||||||
|
docstring += "".join("{}\n".format(line) for line in div_lines)
|
||||||
|
|
||||||
|
wrapped.__doc__ = docstring
|
||||||
|
if self.directive in {"versionadded", "versionchanged"}:
|
||||||
|
return wrapped
|
||||||
|
return super(SphinxAdapter, self).__call__(wrapped)
|
||||||
|
|
||||||
|
def get_deprecated_msg(self, wrapped, instance):
|
||||||
|
"""
|
||||||
|
Get the deprecation warning message (without Sphinx cross-referencing syntax) for the user.
|
||||||
|
|
||||||
|
:param wrapped: Wrapped class or function.
|
||||||
|
|
||||||
|
:param instance: The object to which the wrapped function was bound when it was called.
|
||||||
|
|
||||||
|
:return: The warning message.
|
||||||
|
|
||||||
|
.. versionadded:: 1.2.12
|
||||||
|
Strip Sphinx cross-referencing syntax from warning message.
|
||||||
|
|
||||||
|
"""
|
||||||
|
msg = super(SphinxAdapter, self).get_deprecated_msg(wrapped, instance)
|
||||||
|
# Strip Sphinx cross-reference syntax (like ":function:", ":py:func:" and ":py:meth:")
|
||||||
|
# Possible values are ":role:`foo`", ":domain:role:`foo`"
|
||||||
|
# where ``role`` and ``domain`` should match "[a-zA-Z]+"
|
||||||
|
msg = re.sub(r"(?: : [a-zA-Z]+ )? : [a-zA-Z]+ : (`[^`]*`)", r"\1", msg, flags=re.X)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def versionadded(reason="", version="", line_length=70):
|
||||||
|
"""
|
||||||
|
This decorator can be used to insert a "versionadded" directive
|
||||||
|
in your function/class docstring in order to document the
|
||||||
|
version of the project which adds this new functionality in your library.
|
||||||
|
|
||||||
|
:param str reason:
|
||||||
|
Reason message which documents the addition in your library (can be omitted).
|
||||||
|
|
||||||
|
:param str version:
|
||||||
|
Version of your project which adds this feature.
|
||||||
|
If you follow the `Semantic Versioning <https://semver.org/>`_,
|
||||||
|
the version number has the format "MAJOR.MINOR.PATCH", and,
|
||||||
|
in the case of a new functionality, the "PATCH" component should be "0".
|
||||||
|
|
||||||
|
:type line_length: int
|
||||||
|
:param line_length:
|
||||||
|
Max line length of the directive text. If non nul, a long text is wrapped in several lines.
|
||||||
|
|
||||||
|
:return: the decorated function.
|
||||||
|
"""
|
||||||
|
adapter = SphinxAdapter(
|
||||||
|
'versionadded',
|
||||||
|
reason=reason,
|
||||||
|
version=version,
|
||||||
|
line_length=line_length,
|
||||||
|
)
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
def versionchanged(reason="", version="", line_length=70):
|
||||||
|
"""
|
||||||
|
This decorator can be used to insert a "versionchanged" directive
|
||||||
|
in your function/class docstring in order to document the
|
||||||
|
version of the project which modifies this functionality in your library.
|
||||||
|
|
||||||
|
:param str reason:
|
||||||
|
Reason message which documents the modification in your library (can be omitted).
|
||||||
|
|
||||||
|
:param str version:
|
||||||
|
Version of your project which modifies this feature.
|
||||||
|
If you follow the `Semantic Versioning <https://semver.org/>`_,
|
||||||
|
the version number has the format "MAJOR.MINOR.PATCH".
|
||||||
|
|
||||||
|
:type line_length: int
|
||||||
|
:param line_length:
|
||||||
|
Max line length of the directive text. If non nul, a long text is wrapped in several lines.
|
||||||
|
|
||||||
|
:return: the decorated function.
|
||||||
|
"""
|
||||||
|
adapter = SphinxAdapter(
|
||||||
|
'versionchanged',
|
||||||
|
reason=reason,
|
||||||
|
version=version,
|
||||||
|
line_length=line_length,
|
||||||
|
)
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
def deprecated(reason="", version="", line_length=70, **kwargs):
|
||||||
|
"""
|
||||||
|
This decorator can be used to insert a "deprecated" directive
|
||||||
|
in your function/class docstring in order to document the
|
||||||
|
version of the project which deprecates this functionality in your library.
|
||||||
|
|
||||||
|
:param str reason:
|
||||||
|
Reason message which documents the deprecation in your library (can be omitted).
|
||||||
|
|
||||||
|
:param str version:
|
||||||
|
Version of your project which deprecates this feature.
|
||||||
|
If you follow the `Semantic Versioning <https://semver.org/>`_,
|
||||||
|
the version number has the format "MAJOR.MINOR.PATCH".
|
||||||
|
|
||||||
|
:type line_length: int
|
||||||
|
:param line_length:
|
||||||
|
Max line length of the directive text. If non nul, a long text is wrapped in several lines.
|
||||||
|
|
||||||
|
Keyword arguments can be:
|
||||||
|
|
||||||
|
- "action":
|
||||||
|
A warning filter used to activate or not the deprecation warning.
|
||||||
|
Can be one of "error", "ignore", "always", "default", "module", or "once".
|
||||||
|
If ``None``, empty or missing, the global filtering mechanism is used.
|
||||||
|
|
||||||
|
- "category":
|
||||||
|
The warning category to use for the deprecation warning.
|
||||||
|
By default, the category class is :class:`~DeprecationWarning`,
|
||||||
|
you can inherit this class to define your own deprecation warning category.
|
||||||
|
|
||||||
|
- "extra_stacklevel":
|
||||||
|
Number of additional stack levels to consider instrumentation rather than user code.
|
||||||
|
With the default value of 0, the warning refers to where the class was instantiated
|
||||||
|
or the function was called.
|
||||||
|
|
||||||
|
|
||||||
|
:return: a decorator used to deprecate a function.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2.13
|
||||||
|
Change the signature of the decorator to reflect the valid use cases.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2.15
|
||||||
|
Add the *extra_stacklevel* parameter.
|
||||||
|
"""
|
||||||
|
directive = kwargs.pop('directive', 'deprecated')
|
||||||
|
adapter_cls = kwargs.pop('adapter_cls', SphinxAdapter)
|
||||||
|
kwargs["reason"] = reason
|
||||||
|
kwargs["version"] = version
|
||||||
|
kwargs["line_length"] = line_length
|
||||||
|
return _classic_deprecated(directive=directive, adapter_cls=adapter_cls, **kwargs)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: Flask-Limiter
|
||||||
|
Version: 4.0.0
|
||||||
|
Summary: Rate limiting for flask applications
|
||||||
|
Project-URL: Homepage, https://flask-limiter.readthedocs.org
|
||||||
|
Project-URL: Source, https://github.com/alisaifee/flask-limiter
|
||||||
|
Project-URL: Documentation, https://flask-limiter.readthedocs.org
|
||||||
|
Author-email: Ali-Akber Saifee <ali@indydevs.org>
|
||||||
|
Maintainer-email: Ali-Akber Saifee <ali@indydevs.org>
|
||||||
|
License-Expression: MIT
|
||||||
|
License-File: LICENSE.txt
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Environment :: Web Environment
|
||||||
|
Classifier: Framework :: Flask
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: Operating System :: MacOS
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Operating System :: POSIX :: Linux
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Requires-Dist: flask>=2
|
||||||
|
Requires-Dist: limits>=3.13
|
||||||
|
Requires-Dist: ordered-set<5,>4
|
||||||
|
Requires-Dist: rich<15,>=12
|
||||||
|
Requires-Dist: typing-extensions>=4.3
|
||||||
|
Provides-Extra: memcached
|
||||||
|
Requires-Dist: limits[memcached]; extra == 'memcached'
|
||||||
|
Provides-Extra: mongodb
|
||||||
|
Requires-Dist: limits[mongodb]; extra == 'mongodb'
|
||||||
|
Provides-Extra: redis
|
||||||
|
Requires-Dist: limits[redis]; extra == 'redis'
|
||||||
|
Provides-Extra: valkey
|
||||||
|
Requires-Dist: limits[valkey]; extra == 'valkey'
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
|
||||||
|
.. |ci| image:: https://github.com/alisaifee/flask-limiter/actions/workflows/main.yml/badge.svg?branch=master
|
||||||
|
:target: https://github.com/alisaifee/flask-limiter/actions?query=branch%3Amaster+workflow%3ACI
|
||||||
|
.. |codecov| image:: https://codecov.io/gh/alisaifee/flask-limiter/branch/master/graph/badge.svg
|
||||||
|
:target: https://codecov.io/gh/alisaifee/flask-limiter
|
||||||
|
.. |pypi| image:: https://img.shields.io/pypi/v/Flask-Limiter.svg?style=flat-square
|
||||||
|
:target: https://pypi.python.org/pypi/Flask-Limiter
|
||||||
|
.. |license| image:: https://img.shields.io/pypi/l/Flask-Limiter.svg?style=flat-square
|
||||||
|
:target: https://pypi.python.org/pypi/Flask-Limiter
|
||||||
|
.. |docs| image:: https://readthedocs.org/projects/flask-limiter/badge/?version=latest
|
||||||
|
:target: https://flask-limiter.readthedocs.org/en/latest
|
||||||
|
|
||||||
|
*************
|
||||||
|
Flask-Limiter
|
||||||
|
*************
|
||||||
|
|
||||||
|
|
||||||
|
|docs| |ci| |codecov| |pypi| |license|
|
||||||
|
|
||||||
|
**Flask-Limiter** adds rate limiting to `Flask <https://flask.palletsprojects.com>`_ applications.
|
||||||
|
|
||||||
|
You can configure rate limits at different levels such as:
|
||||||
|
|
||||||
|
- Application wide global limits per user
|
||||||
|
- Default limits per route
|
||||||
|
- By `Blueprints <https://flask-limiter.readthedocs.io/en/latest/recipes.html#rate-limiting-all-routes-in-a-blueprint>`_
|
||||||
|
- By `Class-based views <https://flask-limiter.readthedocs.io/en/latest/recipes.html#using-flask-pluggable-views>`_
|
||||||
|
- By `individual routes <https://flask-limiter.readthedocs.io/en/latest/index.html#decorators-to-declare-rate-limits>`_
|
||||||
|
|
||||||
|
**Flask-Limiter** can be `configured <https://flask-limiter.readthedocs.io/en/latest/configuration.html>`_ to fit your application in many ways, including:
|
||||||
|
|
||||||
|
- Persistance to various commonly used `storage backends <https://flask-limiter.readthedocs.io/en/latest/#configuring-a-storage-backend>`_
|
||||||
|
(such as Redis, Memcached & MongoDB)
|
||||||
|
via `limits <https://limits.readthedocs.io/en/stable/storage.html>`__
|
||||||
|
- Any rate limiting strategy supported by `limits <https://limits.readthedocs.io/en/stable/strategies.html>`__
|
||||||
|
|
||||||
|
Follow the quickstart below to get started or `read the documentation <http://flask-limiter.readthedocs.org/en/latest>`_ for more details.
|
||||||
|
|
||||||
|
|
||||||
|
Quickstart
|
||||||
|
===========
|
||||||
|
|
||||||
|
Install
|
||||||
|
-------
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
pip install Flask-Limiter
|
||||||
|
|
||||||
|
Add the rate limiter to your flask app
|
||||||
|
---------------------------------------
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# app.py
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
limiter = Limiter(
|
||||||
|
get_remote_address,
|
||||||
|
app=app,
|
||||||
|
default_limits=["2 per minute", "1 per second"],
|
||||||
|
storage_uri="memory://",
|
||||||
|
# Redis
|
||||||
|
# storage_uri="redis://localhost:6379",
|
||||||
|
# Redis cluster
|
||||||
|
# storage_uri="redis+cluster://localhost:7000,localhost:7001,localhost:70002",
|
||||||
|
# Memcached
|
||||||
|
# storage_uri="memcached://localhost:11211",
|
||||||
|
# Memcached Cluster
|
||||||
|
# storage_uri="memcached://localhost:11211,localhost:11212,localhost:11213",
|
||||||
|
# MongoDB
|
||||||
|
# storage_uri="mongodb://localhost:27017",
|
||||||
|
strategy="fixed-window", # or "moving-window", or "sliding-window-counter"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/slow")
|
||||||
|
@limiter.limit("1 per day")
|
||||||
|
def slow():
|
||||||
|
return "24"
|
||||||
|
|
||||||
|
@app.route("/fast")
|
||||||
|
def fast():
|
||||||
|
return "42"
|
||||||
|
|
||||||
|
@app.route("/ping")
|
||||||
|
@limiter.exempt
|
||||||
|
def ping():
|
||||||
|
return 'PONG'
|
||||||
|
|
||||||
|
Inspect the limits using the command line interface
|
||||||
|
---------------------------------------------------
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ FLASK_APP=app:app flask limiter limits
|
||||||
|
|
||||||
|
app
|
||||||
|
├── fast: /fast
|
||||||
|
│ ├── 2 per 1 minute
|
||||||
|
│ └── 1 per 1 second
|
||||||
|
├── ping: /ping
|
||||||
|
│ └── Exempt
|
||||||
|
└── slow: /slow
|
||||||
|
└── 1 per 1 day
|
||||||
|
|
||||||
|
Run the app
|
||||||
|
-----------
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ FLASK_APP=app:app flask run
|
||||||
|
|
||||||
|
|
||||||
|
Test it out
|
||||||
|
-----------
|
||||||
|
The ``fast`` endpoint respects the default rate limit while the
|
||||||
|
``slow`` endpoint uses the decorated one. ``ping`` has no rate limit associated
|
||||||
|
with it.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ curl localhost:5000/fast
|
||||||
|
42
|
||||||
|
$ curl localhost:5000/fast
|
||||||
|
42
|
||||||
|
$ curl localhost:5000/fast
|
||||||
|
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
|
||||||
|
<title>429 Too Many Requests</title>
|
||||||
|
<h1>Too Many Requests</h1>
|
||||||
|
<p>2 per 1 minute</p>
|
||||||
|
$ curl localhost:5000/slow
|
||||||
|
24
|
||||||
|
$ curl localhost:5000/slow
|
||||||
|
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
|
||||||
|
<title>429 Too Many Requests</title>
|
||||||
|
<h1>Too Many Requests</h1>
|
||||||
|
<p>1 per 1 day</p>
|
||||||
|
$ curl localhost:5000/ping
|
||||||
|
PONG
|
||||||
|
$ curl localhost:5000/ping
|
||||||
|
PONG
|
||||||
|
$ curl localhost:5000/ping
|
||||||
|
PONG
|
||||||
|
$ curl localhost:5000/ping
|
||||||
|
PONG
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
flask_limiter-4.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
flask_limiter-4.0.0.dist-info/METADATA,sha256=z4cnwjhUEqIaZahFGB30-x1V4Lg_jy5q11Voh_bAMfQ,6190
|
||||||
|
flask_limiter-4.0.0.dist-info/RECORD,,
|
||||||
|
flask_limiter-4.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
flask_limiter-4.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
||||||
|
flask_limiter-4.0.0.dist-info/entry_points.txt,sha256=XP1DLGAtSzSTO-1e0l2FR9chlucKvsGCgh_wfCO9oj8,54
|
||||||
|
flask_limiter-4.0.0.dist-info/licenses/LICENSE.txt,sha256=T6i7kq7F5gIPfcno9FCxU5Hcwm22Bjq0uHZV3ElcjsQ,1061
|
||||||
|
flask_limiter/__init__.py,sha256=bRCHLQM_WY2FAIUOJhTtb3VQ6OXqq79lMz80uub6TNs,557
|
||||||
|
flask_limiter/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/_compat.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/_extension.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/_limits.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/_manager.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/_typing.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/_version.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/commands.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/constants.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/errors.cpython-311.pyc,,
|
||||||
|
flask_limiter/__pycache__/util.cpython-311.pyc,,
|
||||||
|
flask_limiter/_compat.py,sha256=jrUYRoIo4jOXp5JDWgpL77F6Cuj_0iX7ySsTOfYrPs8,379
|
||||||
|
flask_limiter/_extension.py,sha256=QNu9R0u0J2x8Qr9YkOYys3fGA8sLPtX6ZnfZ6lgCG8U,48057
|
||||||
|
flask_limiter/_limits.py,sha256=sJn-5OLYkeS2GfYVkbdSb5flbFidHJnNndCGgbAnYyg,15006
|
||||||
|
flask_limiter/_manager.py,sha256=RJhFo30P8rfNiOtKiKZBUfL1ZYAmuPoXUbsZ4ILB1ew,10453
|
||||||
|
flask_limiter/_typing.py,sha256=yrxK2Zu1sZ3ojvwJMfkJVVawGOzGVsfxAbUG8I5TkBo,401
|
||||||
|
flask_limiter/_version.py,sha256=QKIQLQcx5S9GHF_rplWSVpBW_nVDWWvM96YNckz0xJI,704
|
||||||
|
flask_limiter/_version.pyi,sha256=Y25n44pyE3vp92MiABKrcK3IWRyQ1JG1rZ4Ufqy2nC0,17
|
||||||
|
flask_limiter/commands.py,sha256=meE7MIH0fezy7NnhKGUujsNFlVpCWEZqnu5qoXotseo,22463
|
||||||
|
flask_limiter/constants.py,sha256=-e1Ff1g938ajdgR8f-oWVp3bjLSdy2pPOQdV3RsUHAs,2902
|
||||||
|
flask_limiter/contrib/__init__.py,sha256=Yr06Iy3i_F1cwTSGcGWOxMHOZaQnySiRFBfsH8Syric,28
|
||||||
|
flask_limiter/contrib/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
flask_limiter/contrib/__pycache__/util.cpython-311.pyc,,
|
||||||
|
flask_limiter/contrib/util.py,sha256=XKX5pqA7f-cGP7IQtg2tnyoQk2Eh5L4Hi3zDpWjq_3s,306
|
||||||
|
flask_limiter/errors.py,sha256=mDP2C-SFxaP9ErSZCbSiNmM6RbEZG38EPPPkZELx4K4,1066
|
||||||
|
flask_limiter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
flask_limiter/util.py,sha256=KwLYUluQR2M9dGZppqqczdtUjNiuv2zh_lB_gaDMzcw,975
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: hatchling 1.27.0
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[flask.commands]
|
||||||
|
limiter = flask_limiter.commands:cli
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Copyright (c) 2023 Ali-Akber Saifee
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
@@ -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__
|
||||||
@@ -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
|
||||||
1172
buffteks/lib/python3.11/site-packages/flask_limiter/_extension.py
Normal file
1172
buffteks/lib/python3.11/site-packages/flask_limiter/_extension.py
Normal file
File diff suppressed because it is too large
Load Diff
406
buffteks/lib/python3.11/site-packages/flask_limiter/_limits.py
Normal file
406
buffteks/lib/python3.11/site-packages/flask_limiter/_limits.py
Normal 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
|
||||||
243
buffteks/lib/python3.11/site-packages/flask_limiter/_manager.py
Normal file
243
buffteks/lib/python3.11/site-packages/flask_limiter/_manager.py
Normal 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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__: str
|
||||||
563
buffteks/lib/python3.11/site-packages/flask_limiter/commands.py
Normal file
563
buffteks/lib/python3.11/site-packages/flask_limiter/commands.py
Normal 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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Contributed 'recipes'"""
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
31
buffteks/lib/python3.11/site-packages/flask_limiter/util.py
Normal file
31
buffteks/lib/python3.11/site-packages/flask_limiter/util.py
Normal 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__}"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
Metadata-Version: 2.3
|
||||||
|
Name: Flask-WTF
|
||||||
|
Version: 1.2.2
|
||||||
|
Summary: Form rendering, validation, and CSRF protection for Flask with WTForms.
|
||||||
|
Project-URL: Documentation, https://flask-wtf.readthedocs.io/
|
||||||
|
Project-URL: Changes, https://flask-wtf.readthedocs.io/changes/
|
||||||
|
Project-URL: Source Code, https://github.com/pallets-eco/flask-wtf/
|
||||||
|
Project-URL: Issue Tracker, https://github.com/pallets-eco/flask-wtf/issues/
|
||||||
|
Project-URL: Chat, https://discord.gg/pallets
|
||||||
|
Maintainer: WTForms
|
||||||
|
License: Copyright 2010 WTForms
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||||||
|
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
License-File: LICENSE.rst
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Environment :: Web Environment
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: License :: OSI Approved :: BSD License
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
|
||||||
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
||||||
|
Requires-Python: >=3.9
|
||||||
|
Requires-Dist: flask
|
||||||
|
Requires-Dist: itsdangerous
|
||||||
|
Requires-Dist: wtforms
|
||||||
|
Provides-Extra: email
|
||||||
|
Requires-Dist: email-validator; extra == 'email'
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
|
||||||
|
Flask-WTF
|
||||||
|
=========
|
||||||
|
|
||||||
|
Simple integration of Flask and WTForms, including CSRF, file upload,
|
||||||
|
and reCAPTCHA.
|
||||||
|
|
||||||
|
Links
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Documentation: https://flask-wtf.readthedocs.io/
|
||||||
|
- Changes: https://flask-wtf.readthedocs.io/changes/
|
||||||
|
- PyPI Releases: https://pypi.org/project/Flask-WTF/
|
||||||
|
- Source Code: https://github.com/pallets-eco/flask-wtf/
|
||||||
|
- Issue Tracker: https://github.com/pallets-eco/flask-wtf/issues/
|
||||||
|
- Chat: https://discord.gg/pallets
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
flask_wtf-1.2.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
flask_wtf-1.2.2.dist-info/METADATA,sha256=Z0LaCPBB6RVW1BohxeYvGOkbiAXAtkDhPpNxsXZZkLM,3389
|
||||||
|
flask_wtf-1.2.2.dist-info/RECORD,,
|
||||||
|
flask_wtf-1.2.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
flask_wtf-1.2.2.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
||||||
|
flask_wtf-1.2.2.dist-info/licenses/LICENSE.rst,sha256=1fGQNkUVeMs27u8EyZ6_fXyi5w3PBDY2UZvEIOFafGI,1475
|
||||||
|
flask_wtf/__init__.py,sha256=1v73MaP8sjOe-mJ5DtKaMn_ZvQwAoV5TIxpjE1i3n9A,338
|
||||||
|
flask_wtf/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
flask_wtf/__pycache__/_compat.cpython-311.pyc,,
|
||||||
|
flask_wtf/__pycache__/csrf.cpython-311.pyc,,
|
||||||
|
flask_wtf/__pycache__/file.cpython-311.pyc,,
|
||||||
|
flask_wtf/__pycache__/form.cpython-311.pyc,,
|
||||||
|
flask_wtf/__pycache__/i18n.cpython-311.pyc,,
|
||||||
|
flask_wtf/_compat.py,sha256=N3sqC9yzFWY-3MZ7QazX1sidvkO3d5yy4NR6lkp0s94,248
|
||||||
|
flask_wtf/csrf.py,sha256=O-fjnWygxxi_FsIU2koua97ZpIhiOJVDHA57dXLpvTA,10171
|
||||||
|
flask_wtf/file.py,sha256=E-PvtzlOGqbtsLVkbooDSo_klCf7oWQvTZWk1hkNBoY,4636
|
||||||
|
flask_wtf/form.py,sha256=TmR7xCrxin2LHp6thn7fq1OeU8aLB7xsZzvv52nH7Ss,4049
|
||||||
|
flask_wtf/i18n.py,sha256=TyO8gqt9DocHMSaNhj0KKgxoUrPYs-G1nVW-jns0SOw,1166
|
||||||
|
flask_wtf/recaptcha/__init__.py,sha256=odaCRkvoG999MnGiaSfA1sEt6OnAOqumg78jO53GL-4,168
|
||||||
|
flask_wtf/recaptcha/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
flask_wtf/recaptcha/__pycache__/fields.cpython-311.pyc,,
|
||||||
|
flask_wtf/recaptcha/__pycache__/validators.cpython-311.pyc,,
|
||||||
|
flask_wtf/recaptcha/__pycache__/widgets.cpython-311.pyc,,
|
||||||
|
flask_wtf/recaptcha/fields.py,sha256=M1-RFuUKOsJAzsLm3xaaxuhX2bB9oRqS-HVSN-NpkmI,433
|
||||||
|
flask_wtf/recaptcha/validators.py,sha256=3sd1mUQT3Y3D_WJeKwecxUGstnhh_QD-A_dEBJfkf6s,2434
|
||||||
|
flask_wtf/recaptcha/widgets.py,sha256=J_XyxAZt3uB15diIMnkXXGII2dmsWCsVsKV3KQYn4Ns,1512
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: hatchling 1.25.0
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
Copyright 2010 WTForms
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||||||
|
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
16
buffteks/lib/python3.11/site-packages/flask_wtf/__init__.py
Normal file
16
buffteks/lib/python3.11/site-packages/flask_wtf/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from .csrf import CSRFProtect
|
||||||
|
from .form import FlaskForm
|
||||||
|
from .form import Form
|
||||||
|
from .recaptcha import Recaptcha
|
||||||
|
from .recaptcha import RecaptchaField
|
||||||
|
from .recaptcha import RecaptchaWidget
|
||||||
|
|
||||||
|
__version__ = "1.2.2"
|
||||||
|
__all__ = [
|
||||||
|
"CSRFProtect",
|
||||||
|
"FlaskForm",
|
||||||
|
"Form",
|
||||||
|
"Recaptcha",
|
||||||
|
"RecaptchaField",
|
||||||
|
"RecaptchaWidget",
|
||||||
|
]
|
||||||
11
buffteks/lib/python3.11/site-packages/flask_wtf/_compat.py
Normal file
11
buffteks/lib/python3.11/site-packages/flask_wtf/_compat.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
|
class FlaskWTFDeprecationWarning(DeprecationWarning):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
warnings.simplefilter("always", FlaskWTFDeprecationWarning)
|
||||||
|
warnings.filterwarnings(
|
||||||
|
"ignore", category=FlaskWTFDeprecationWarning, module="wtforms|flask_wtf"
|
||||||
|
)
|
||||||
329
buffteks/lib/python3.11/site-packages/flask_wtf/csrf.py
Normal file
329
buffteks/lib/python3.11/site-packages/flask_wtf/csrf.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
from flask import current_app
|
||||||
|
from flask import g
|
||||||
|
from flask import request
|
||||||
|
from flask import session
|
||||||
|
from itsdangerous import BadData
|
||||||
|
from itsdangerous import SignatureExpired
|
||||||
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
|
from werkzeug.exceptions import BadRequest
|
||||||
|
from wtforms import ValidationError
|
||||||
|
from wtforms.csrf.core import CSRF
|
||||||
|
|
||||||
|
__all__ = ("generate_csrf", "validate_csrf", "CSRFProtect")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_csrf(secret_key=None, token_key=None):
|
||||||
|
"""Generate a CSRF token. The token is cached for a request, so multiple
|
||||||
|
calls to this function will generate the same token.
|
||||||
|
|
||||||
|
During testing, it might be useful to access the signed token in
|
||||||
|
``g.csrf_token`` and the raw token in ``session['csrf_token']``.
|
||||||
|
|
||||||
|
:param secret_key: Used to securely sign the token. Default is
|
||||||
|
``WTF_CSRF_SECRET_KEY`` or ``SECRET_KEY``.
|
||||||
|
:param token_key: Key where token is stored in session for comparison.
|
||||||
|
Default is ``WTF_CSRF_FIELD_NAME`` or ``'csrf_token'``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
secret_key = _get_config(
|
||||||
|
secret_key,
|
||||||
|
"WTF_CSRF_SECRET_KEY",
|
||||||
|
current_app.secret_key,
|
||||||
|
message="A secret key is required to use CSRF.",
|
||||||
|
)
|
||||||
|
field_name = _get_config(
|
||||||
|
token_key,
|
||||||
|
"WTF_CSRF_FIELD_NAME",
|
||||||
|
"csrf_token",
|
||||||
|
message="A field name is required to use CSRF.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if field_name not in g:
|
||||||
|
s = URLSafeTimedSerializer(secret_key, salt="wtf-csrf-token")
|
||||||
|
|
||||||
|
if field_name not in session:
|
||||||
|
session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = s.dumps(session[field_name])
|
||||||
|
except TypeError:
|
||||||
|
session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest()
|
||||||
|
token = s.dumps(session[field_name])
|
||||||
|
|
||||||
|
setattr(g, field_name, token)
|
||||||
|
|
||||||
|
return g.get(field_name)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_csrf(data, secret_key=None, time_limit=None, token_key=None):
|
||||||
|
"""Check if the given data is a valid CSRF token. This compares the given
|
||||||
|
signed token to the one stored in the session.
|
||||||
|
|
||||||
|
:param data: The signed CSRF token to be checked.
|
||||||
|
:param secret_key: Used to securely sign the token. Default is
|
||||||
|
``WTF_CSRF_SECRET_KEY`` or ``SECRET_KEY``.
|
||||||
|
:param time_limit: Number of seconds that the token is valid. Default is
|
||||||
|
``WTF_CSRF_TIME_LIMIT`` or 3600 seconds (60 minutes).
|
||||||
|
:param token_key: Key where token is stored in session for comparison.
|
||||||
|
Default is ``WTF_CSRF_FIELD_NAME`` or ``'csrf_token'``.
|
||||||
|
|
||||||
|
:raises ValidationError: Contains the reason that validation failed.
|
||||||
|
|
||||||
|
.. versionchanged:: 0.14
|
||||||
|
Raises ``ValidationError`` with a specific error message rather than
|
||||||
|
returning ``True`` or ``False``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
secret_key = _get_config(
|
||||||
|
secret_key,
|
||||||
|
"WTF_CSRF_SECRET_KEY",
|
||||||
|
current_app.secret_key,
|
||||||
|
message="A secret key is required to use CSRF.",
|
||||||
|
)
|
||||||
|
field_name = _get_config(
|
||||||
|
token_key,
|
||||||
|
"WTF_CSRF_FIELD_NAME",
|
||||||
|
"csrf_token",
|
||||||
|
message="A field name is required to use CSRF.",
|
||||||
|
)
|
||||||
|
time_limit = _get_config(time_limit, "WTF_CSRF_TIME_LIMIT", 3600, required=False)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise ValidationError("The CSRF token is missing.")
|
||||||
|
|
||||||
|
if field_name not in session:
|
||||||
|
raise ValidationError("The CSRF session token is missing.")
|
||||||
|
|
||||||
|
s = URLSafeTimedSerializer(secret_key, salt="wtf-csrf-token")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = s.loads(data, max_age=time_limit)
|
||||||
|
except SignatureExpired as e:
|
||||||
|
raise ValidationError("The CSRF token has expired.") from e
|
||||||
|
except BadData as e:
|
||||||
|
raise ValidationError("The CSRF token is invalid.") from e
|
||||||
|
|
||||||
|
if not hmac.compare_digest(session[field_name], token):
|
||||||
|
raise ValidationError("The CSRF tokens do not match.")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config(
|
||||||
|
value, config_name, default=None, required=True, message="CSRF is not configured."
|
||||||
|
):
|
||||||
|
"""Find config value based on provided value, Flask config, and default
|
||||||
|
value.
|
||||||
|
|
||||||
|
:param value: already provided config value
|
||||||
|
:param config_name: Flask ``config`` key
|
||||||
|
:param default: default value if not provided or configured
|
||||||
|
:param required: whether the value must not be ``None``
|
||||||
|
:param message: error message if required config is not found
|
||||||
|
:raises KeyError: if required config is not found
|
||||||
|
"""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
value = current_app.config.get(config_name, default)
|
||||||
|
|
||||||
|
if required and value is None:
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class _FlaskFormCSRF(CSRF):
|
||||||
|
def setup_form(self, form):
|
||||||
|
self.meta = form.meta
|
||||||
|
return super().setup_form(form)
|
||||||
|
|
||||||
|
def generate_csrf_token(self, csrf_token_field):
|
||||||
|
return generate_csrf(
|
||||||
|
secret_key=self.meta.csrf_secret, token_key=self.meta.csrf_field_name
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_csrf_token(self, form, field):
|
||||||
|
if g.get("csrf_valid", False):
|
||||||
|
# already validated by CSRFProtect
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_csrf(
|
||||||
|
field.data,
|
||||||
|
self.meta.csrf_secret,
|
||||||
|
self.meta.csrf_time_limit,
|
||||||
|
self.meta.csrf_field_name,
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.info(e.args[0])
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class CSRFProtect:
|
||||||
|
"""Enable CSRF protection globally for a Flask app.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
|
Checks the ``csrf_token`` field sent with forms, or the ``X-CSRFToken``
|
||||||
|
header sent with JavaScript requests. Render the token in templates using
|
||||||
|
``{{ csrf_token() }}``.
|
||||||
|
|
||||||
|
See the :ref:`csrf` documentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app=None):
|
||||||
|
self._exempt_views = set()
|
||||||
|
self._exempt_blueprints = set()
|
||||||
|
|
||||||
|
if app:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
app.extensions["csrf"] = self
|
||||||
|
|
||||||
|
app.config.setdefault("WTF_CSRF_ENABLED", True)
|
||||||
|
app.config.setdefault("WTF_CSRF_CHECK_DEFAULT", True)
|
||||||
|
app.config["WTF_CSRF_METHODS"] = set(
|
||||||
|
app.config.get("WTF_CSRF_METHODS", ["POST", "PUT", "PATCH", "DELETE"])
|
||||||
|
)
|
||||||
|
app.config.setdefault("WTF_CSRF_FIELD_NAME", "csrf_token")
|
||||||
|
app.config.setdefault("WTF_CSRF_HEADERS", ["X-CSRFToken", "X-CSRF-Token"])
|
||||||
|
app.config.setdefault("WTF_CSRF_TIME_LIMIT", 3600)
|
||||||
|
app.config.setdefault("WTF_CSRF_SSL_STRICT", True)
|
||||||
|
|
||||||
|
app.jinja_env.globals["csrf_token"] = generate_csrf
|
||||||
|
app.context_processor(lambda: {"csrf_token": generate_csrf})
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def csrf_protect():
|
||||||
|
if not app.config["WTF_CSRF_ENABLED"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not app.config["WTF_CSRF_CHECK_DEFAULT"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
if request.method not in app.config["WTF_CSRF_METHODS"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not request.endpoint:
|
||||||
|
return
|
||||||
|
|
||||||
|
if app.blueprints.get(request.blueprint) in self._exempt_blueprints:
|
||||||
|
return
|
||||||
|
|
||||||
|
view = app.view_functions.get(request.endpoint)
|
||||||
|
dest = f"{view.__module__}.{view.__name__}"
|
||||||
|
|
||||||
|
if dest in self._exempt_views:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.protect()
|
||||||
|
|
||||||
|
def _get_csrf_token(self):
|
||||||
|
# find the token in the form data
|
||||||
|
field_name = current_app.config["WTF_CSRF_FIELD_NAME"]
|
||||||
|
base_token = request.form.get(field_name)
|
||||||
|
|
||||||
|
if base_token:
|
||||||
|
return base_token
|
||||||
|
|
||||||
|
# if the form has a prefix, the name will be {prefix}-csrf_token
|
||||||
|
for key in request.form:
|
||||||
|
if key.endswith(field_name):
|
||||||
|
csrf_token = request.form[key]
|
||||||
|
|
||||||
|
if csrf_token:
|
||||||
|
return csrf_token
|
||||||
|
|
||||||
|
# find the token in the headers
|
||||||
|
for header_name in current_app.config["WTF_CSRF_HEADERS"]:
|
||||||
|
csrf_token = request.headers.get(header_name)
|
||||||
|
|
||||||
|
if csrf_token:
|
||||||
|
return csrf_token
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def protect(self):
|
||||||
|
if request.method not in current_app.config["WTF_CSRF_METHODS"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_csrf(self._get_csrf_token())
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.info(e.args[0])
|
||||||
|
self._error_response(e.args[0])
|
||||||
|
|
||||||
|
if request.is_secure and current_app.config["WTF_CSRF_SSL_STRICT"]:
|
||||||
|
if not request.referrer:
|
||||||
|
self._error_response("The referrer header is missing.")
|
||||||
|
|
||||||
|
good_referrer = f"https://{request.host}/"
|
||||||
|
|
||||||
|
if not same_origin(request.referrer, good_referrer):
|
||||||
|
self._error_response("The referrer does not match the host.")
|
||||||
|
|
||||||
|
g.csrf_valid = True # mark this request as CSRF valid
|
||||||
|
|
||||||
|
def exempt(self, view):
|
||||||
|
"""Mark a view or blueprint to be excluded from CSRF protection.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
@app.route('/some-view', methods=['POST'])
|
||||||
|
@csrf.exempt
|
||||||
|
def some_view():
|
||||||
|
...
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
bp = Blueprint(...)
|
||||||
|
csrf.exempt(bp)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(view, Blueprint):
|
||||||
|
self._exempt_blueprints.add(view)
|
||||||
|
return view
|
||||||
|
|
||||||
|
if isinstance(view, str):
|
||||||
|
view_location = view
|
||||||
|
else:
|
||||||
|
view_location = ".".join((view.__module__, view.__name__))
|
||||||
|
|
||||||
|
self._exempt_views.add(view_location)
|
||||||
|
return view
|
||||||
|
|
||||||
|
def _error_response(self, reason):
|
||||||
|
raise CSRFError(reason)
|
||||||
|
|
||||||
|
|
||||||
|
class CSRFError(BadRequest):
|
||||||
|
"""Raise if the client sends invalid CSRF data with the request.
|
||||||
|
|
||||||
|
Generates a 400 Bad Request response with the failure reason by default.
|
||||||
|
Customize the response by registering a handler with
|
||||||
|
:meth:`flask.Flask.errorhandler`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
description = "CSRF validation failed."
|
||||||
|
|
||||||
|
|
||||||
|
def same_origin(current_uri, compare_uri):
|
||||||
|
current = urlparse(current_uri)
|
||||||
|
compare = urlparse(compare_uri)
|
||||||
|
|
||||||
|
return (
|
||||||
|
current.scheme == compare.scheme
|
||||||
|
and current.hostname == compare.hostname
|
||||||
|
and current.port == compare.port
|
||||||
|
)
|
||||||
146
buffteks/lib/python3.11/site-packages/flask_wtf/file.py
Normal file
146
buffteks/lib/python3.11/site-packages/flask_wtf/file.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from collections import abc
|
||||||
|
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
from wtforms import FileField as _FileField
|
||||||
|
from wtforms import MultipleFileField as _MultipleFileField
|
||||||
|
from wtforms.validators import DataRequired
|
||||||
|
from wtforms.validators import StopValidation
|
||||||
|
from wtforms.validators import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class FileField(_FileField):
|
||||||
|
"""Werkzeug-aware subclass of :class:`wtforms.fields.FileField`."""
|
||||||
|
|
||||||
|
def process_formdata(self, valuelist):
|
||||||
|
valuelist = (x for x in valuelist if isinstance(x, FileStorage) and x)
|
||||||
|
data = next(valuelist, None)
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
self.data = data
|
||||||
|
else:
|
||||||
|
self.raw_data = ()
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleFileField(_MultipleFileField):
|
||||||
|
"""Werkzeug-aware subclass of :class:`wtforms.fields.MultipleFileField`.
|
||||||
|
|
||||||
|
.. versionadded:: 1.2.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process_formdata(self, valuelist):
|
||||||
|
valuelist = (x for x in valuelist if isinstance(x, FileStorage) and x)
|
||||||
|
data = list(valuelist) or None
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
self.data = data
|
||||||
|
else:
|
||||||
|
self.raw_data = ()
|
||||||
|
|
||||||
|
|
||||||
|
class FileRequired(DataRequired):
|
||||||
|
"""Validates that the uploaded files(s) is a Werkzeug
|
||||||
|
:class:`~werkzeug.datastructures.FileStorage` object.
|
||||||
|
|
||||||
|
:param message: error message
|
||||||
|
|
||||||
|
You can also use the synonym ``file_required``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __call__(self, form, field):
|
||||||
|
field_data = [field.data] if not isinstance(field.data, list) else field.data
|
||||||
|
if not (
|
||||||
|
all(isinstance(x, FileStorage) and x for x in field_data) and field_data
|
||||||
|
):
|
||||||
|
raise StopValidation(
|
||||||
|
self.message or field.gettext("This field is required.")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
file_required = FileRequired
|
||||||
|
|
||||||
|
|
||||||
|
class FileAllowed:
|
||||||
|
"""Validates that the uploaded file(s) is allowed by a given list of
|
||||||
|
extensions or a Flask-Uploads :class:`~flaskext.uploads.UploadSet`.
|
||||||
|
|
||||||
|
:param upload_set: A list of extensions or an
|
||||||
|
:class:`~flaskext.uploads.UploadSet`
|
||||||
|
:param message: error message
|
||||||
|
|
||||||
|
You can also use the synonym ``file_allowed``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, upload_set, message=None):
|
||||||
|
self.upload_set = upload_set
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __call__(self, form, field):
|
||||||
|
field_data = [field.data] if not isinstance(field.data, list) else field.data
|
||||||
|
if not (
|
||||||
|
all(isinstance(x, FileStorage) and x for x in field_data) and field_data
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
filenames = [f.filename.lower() for f in field_data]
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
if isinstance(self.upload_set, abc.Iterable):
|
||||||
|
if any(filename.endswith("." + x) for x in self.upload_set):
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise StopValidation(
|
||||||
|
self.message
|
||||||
|
or field.gettext(
|
||||||
|
"File does not have an approved extension: {extensions}"
|
||||||
|
).format(extensions=", ".join(self.upload_set))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.upload_set.file_allowed(field_data, filename):
|
||||||
|
raise StopValidation(
|
||||||
|
self.message
|
||||||
|
or field.gettext("File does not have an approved extension.")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
file_allowed = FileAllowed
|
||||||
|
|
||||||
|
|
||||||
|
class FileSize:
|
||||||
|
"""Validates that the uploaded file(s) is within a minimum and maximum
|
||||||
|
file size (set in bytes).
|
||||||
|
|
||||||
|
:param min_size: minimum allowed file size (in bytes). Defaults to 0 bytes.
|
||||||
|
:param max_size: maximum allowed file size (in bytes).
|
||||||
|
:param message: error message
|
||||||
|
|
||||||
|
You can also use the synonym ``file_size``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_size, min_size=0, message=None):
|
||||||
|
self.min_size = min_size
|
||||||
|
self.max_size = max_size
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __call__(self, form, field):
|
||||||
|
field_data = [field.data] if not isinstance(field.data, list) else field.data
|
||||||
|
if not (
|
||||||
|
all(isinstance(x, FileStorage) and x for x in field_data) and field_data
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
for f in field_data:
|
||||||
|
file_size = len(f.read())
|
||||||
|
f.seek(0) # reset cursor position to beginning of file
|
||||||
|
|
||||||
|
if (file_size < self.min_size) or (file_size > self.max_size):
|
||||||
|
# the file is too small or too big => validation failure
|
||||||
|
raise ValidationError(
|
||||||
|
self.message
|
||||||
|
or field.gettext(
|
||||||
|
f"File must be between {self.min_size}"
|
||||||
|
f" and {self.max_size} bytes."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
file_size = FileSize
|
||||||
127
buffteks/lib/python3.11/site-packages/flask_wtf/form.py
Normal file
127
buffteks/lib/python3.11/site-packages/flask_wtf/form.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from flask import current_app
|
||||||
|
from flask import request
|
||||||
|
from flask import session
|
||||||
|
from markupsafe import Markup
|
||||||
|
from werkzeug.datastructures import CombinedMultiDict
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
from werkzeug.utils import cached_property
|
||||||
|
from wtforms import Form
|
||||||
|
from wtforms.meta import DefaultMeta
|
||||||
|
from wtforms.widgets import HiddenInput
|
||||||
|
|
||||||
|
from .csrf import _FlaskFormCSRF
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .i18n import translations
|
||||||
|
except ImportError:
|
||||||
|
translations = None # babel not installed
|
||||||
|
|
||||||
|
|
||||||
|
SUBMIT_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
_Auto = object()
|
||||||
|
|
||||||
|
|
||||||
|
class FlaskForm(Form):
|
||||||
|
"""Flask-specific subclass of WTForms :class:`~wtforms.form.Form`.
|
||||||
|
|
||||||
|
If ``formdata`` is not specified, this will use :attr:`flask.request.form`
|
||||||
|
and :attr:`flask.request.files`. Explicitly pass ``formdata=None`` to
|
||||||
|
prevent this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta(DefaultMeta):
|
||||||
|
csrf_class = _FlaskFormCSRF
|
||||||
|
csrf_context = session # not used, provided for custom csrf_class
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def csrf(self):
|
||||||
|
return current_app.config.get("WTF_CSRF_ENABLED", True)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def csrf_secret(self):
|
||||||
|
return current_app.config.get("WTF_CSRF_SECRET_KEY", current_app.secret_key)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def csrf_field_name(self):
|
||||||
|
return current_app.config.get("WTF_CSRF_FIELD_NAME", "csrf_token")
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def csrf_time_limit(self):
|
||||||
|
return current_app.config.get("WTF_CSRF_TIME_LIMIT", 3600)
|
||||||
|
|
||||||
|
def wrap_formdata(self, form, formdata):
|
||||||
|
if formdata is _Auto:
|
||||||
|
if _is_submitted():
|
||||||
|
if request.files:
|
||||||
|
return CombinedMultiDict((request.files, request.form))
|
||||||
|
elif request.form:
|
||||||
|
return request.form
|
||||||
|
elif request.is_json:
|
||||||
|
return ImmutableMultiDict(request.get_json())
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
return formdata
|
||||||
|
|
||||||
|
def get_translations(self, form):
|
||||||
|
if not current_app.config.get("WTF_I18N_ENABLED", True):
|
||||||
|
return super().get_translations(form)
|
||||||
|
|
||||||
|
return translations
|
||||||
|
|
||||||
|
def __init__(self, formdata=_Auto, **kwargs):
|
||||||
|
super().__init__(formdata=formdata, **kwargs)
|
||||||
|
|
||||||
|
def is_submitted(self):
|
||||||
|
"""Consider the form submitted if there is an active request and
|
||||||
|
the method is ``POST``, ``PUT``, ``PATCH``, or ``DELETE``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _is_submitted()
|
||||||
|
|
||||||
|
def validate_on_submit(self, extra_validators=None):
|
||||||
|
"""Call :meth:`validate` only if the form is submitted.
|
||||||
|
This is a shortcut for ``form.is_submitted() and form.validate()``.
|
||||||
|
"""
|
||||||
|
return self.is_submitted() and self.validate(extra_validators=extra_validators)
|
||||||
|
|
||||||
|
def hidden_tag(self, *fields):
|
||||||
|
"""Render the form's hidden fields in one call.
|
||||||
|
|
||||||
|
A field is considered hidden if it uses the
|
||||||
|
:class:`~wtforms.widgets.HiddenInput` widget.
|
||||||
|
|
||||||
|
If ``fields`` are given, only render the given fields that
|
||||||
|
are hidden. If a string is passed, render the field with that
|
||||||
|
name if it exists.
|
||||||
|
|
||||||
|
.. versionchanged:: 0.13
|
||||||
|
|
||||||
|
No longer wraps inputs in hidden div.
|
||||||
|
This is valid HTML 5.
|
||||||
|
|
||||||
|
.. versionchanged:: 0.13
|
||||||
|
|
||||||
|
Skip passed fields that aren't hidden.
|
||||||
|
Skip passed names that don't exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def hidden_fields(fields):
|
||||||
|
for f in fields:
|
||||||
|
if isinstance(f, str):
|
||||||
|
f = getattr(self, f, None)
|
||||||
|
|
||||||
|
if f is None or not isinstance(f.widget, HiddenInput):
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield f
|
||||||
|
|
||||||
|
return Markup("\n".join(str(f) for f in hidden_fields(fields or self)))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_submitted():
|
||||||
|
"""Consider the form submitted if there is an active request and
|
||||||
|
the method is ``POST``, ``PUT``, ``PATCH``, or ``DELETE``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return bool(request) and request.method in SUBMIT_METHODS
|
||||||
47
buffteks/lib/python3.11/site-packages/flask_wtf/i18n.py
Normal file
47
buffteks/lib/python3.11/site-packages/flask_wtf/i18n.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from babel import support
|
||||||
|
from flask import current_app
|
||||||
|
from flask import request
|
||||||
|
from flask_babel import get_locale
|
||||||
|
from wtforms.i18n import messages_path
|
||||||
|
|
||||||
|
__all__ = ("Translations", "translations")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_translations():
|
||||||
|
"""Returns the correct gettext translations.
|
||||||
|
Copy from flask-babel with some modifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not request:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# babel should be in extensions for get_locale
|
||||||
|
if "babel" not in current_app.extensions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
translations = getattr(request, "wtforms_translations", None)
|
||||||
|
|
||||||
|
if translations is None:
|
||||||
|
translations = support.Translations.load(
|
||||||
|
messages_path(), [get_locale()], domain="wtforms"
|
||||||
|
)
|
||||||
|
request.wtforms_translations = translations
|
||||||
|
|
||||||
|
return translations
|
||||||
|
|
||||||
|
|
||||||
|
class Translations:
|
||||||
|
def gettext(self, string):
|
||||||
|
t = _get_translations()
|
||||||
|
return string if t is None else t.ugettext(string)
|
||||||
|
|
||||||
|
def ngettext(self, singular, plural, n):
|
||||||
|
t = _get_translations()
|
||||||
|
|
||||||
|
if t is None:
|
||||||
|
return singular if n == 1 else plural
|
||||||
|
|
||||||
|
return t.ungettext(singular, plural, n)
|
||||||
|
|
||||||
|
|
||||||
|
translations = Translations()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .fields import RecaptchaField
|
||||||
|
from .validators import Recaptcha
|
||||||
|
from .widgets import RecaptchaWidget
|
||||||
|
|
||||||
|
__all__ = ["RecaptchaField", "RecaptchaWidget", "Recaptcha"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from wtforms.fields import Field
|
||||||
|
|
||||||
|
from . import widgets
|
||||||
|
from .validators import Recaptcha
|
||||||
|
|
||||||
|
__all__ = ["RecaptchaField"]
|
||||||
|
|
||||||
|
|
||||||
|
class RecaptchaField(Field):
|
||||||
|
widget = widgets.RecaptchaWidget()
|
||||||
|
|
||||||
|
# error message if recaptcha validation fails
|
||||||
|
recaptcha_error = None
|
||||||
|
|
||||||
|
def __init__(self, label="", validators=None, **kwargs):
|
||||||
|
validators = validators or [Recaptcha()]
|
||||||
|
super().__init__(label, validators, **kwargs)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
from urllib import request as http
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from flask import request
|
||||||
|
from wtforms import ValidationError
|
||||||
|
|
||||||
|
RECAPTCHA_VERIFY_SERVER_DEFAULT = "https://www.google.com/recaptcha/api/siteverify"
|
||||||
|
RECAPTCHA_ERROR_CODES = {
|
||||||
|
"missing-input-secret": "The secret parameter is missing.",
|
||||||
|
"invalid-input-secret": "The secret parameter is invalid or malformed.",
|
||||||
|
"missing-input-response": "The response parameter is missing.",
|
||||||
|
"invalid-input-response": "The response parameter is invalid or malformed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Recaptcha"]
|
||||||
|
|
||||||
|
|
||||||
|
class Recaptcha:
|
||||||
|
"""Validates a ReCaptcha."""
|
||||||
|
|
||||||
|
def __init__(self, message=None):
|
||||||
|
if message is None:
|
||||||
|
message = RECAPTCHA_ERROR_CODES["missing-input-response"]
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __call__(self, form, field):
|
||||||
|
if current_app.testing:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if request.is_json:
|
||||||
|
response = request.json.get("g-recaptcha-response", "")
|
||||||
|
else:
|
||||||
|
response = request.form.get("g-recaptcha-response", "")
|
||||||
|
remote_ip = request.remote_addr
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
raise ValidationError(field.gettext(self.message))
|
||||||
|
|
||||||
|
if not self._validate_recaptcha(response, remote_ip):
|
||||||
|
field.recaptcha_error = "incorrect-captcha-sol"
|
||||||
|
raise ValidationError(field.gettext(self.message))
|
||||||
|
|
||||||
|
def _validate_recaptcha(self, response, remote_addr):
|
||||||
|
"""Performs the actual validation."""
|
||||||
|
try:
|
||||||
|
private_key = current_app.config["RECAPTCHA_PRIVATE_KEY"]
|
||||||
|
except KeyError:
|
||||||
|
raise RuntimeError("No RECAPTCHA_PRIVATE_KEY config set") from None
|
||||||
|
|
||||||
|
verify_server = current_app.config.get("RECAPTCHA_VERIFY_SERVER")
|
||||||
|
if not verify_server:
|
||||||
|
verify_server = RECAPTCHA_VERIFY_SERVER_DEFAULT
|
||||||
|
|
||||||
|
data = urlencode(
|
||||||
|
{"secret": private_key, "remoteip": remote_addr, "response": response}
|
||||||
|
)
|
||||||
|
|
||||||
|
http_response = http.urlopen(verify_server, data.encode("utf-8"))
|
||||||
|
|
||||||
|
if http_response.code != 200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
json_resp = json.loads(http_response.read())
|
||||||
|
|
||||||
|
if json_resp["success"]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for error in json_resp.get("error-codes", []):
|
||||||
|
if error in RECAPTCHA_ERROR_CODES:
|
||||||
|
raise ValidationError(RECAPTCHA_ERROR_CODES[error])
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
RECAPTCHA_SCRIPT_DEFAULT = "https://www.google.com/recaptcha/api.js"
|
||||||
|
RECAPTCHA_DIV_CLASS_DEFAULT = "g-recaptcha"
|
||||||
|
RECAPTCHA_TEMPLATE = """
|
||||||
|
<script src='%s' async defer></script>
|
||||||
|
<div class="%s" %s></div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["RecaptchaWidget"]
|
||||||
|
|
||||||
|
|
||||||
|
class RecaptchaWidget:
|
||||||
|
def recaptcha_html(self, public_key):
|
||||||
|
html = current_app.config.get("RECAPTCHA_HTML")
|
||||||
|
if html:
|
||||||
|
return Markup(html)
|
||||||
|
params = current_app.config.get("RECAPTCHA_PARAMETERS")
|
||||||
|
script = current_app.config.get("RECAPTCHA_SCRIPT")
|
||||||
|
if not script:
|
||||||
|
script = RECAPTCHA_SCRIPT_DEFAULT
|
||||||
|
if params:
|
||||||
|
script += "?" + urlencode(params)
|
||||||
|
attrs = current_app.config.get("RECAPTCHA_DATA_ATTRS", {})
|
||||||
|
attrs["sitekey"] = public_key
|
||||||
|
snippet = " ".join(f'data-{k}="{attrs[k]}"' for k in attrs) # noqa: B028, B907
|
||||||
|
div_class = current_app.config.get("RECAPTCHA_DIV_CLASS")
|
||||||
|
if not div_class:
|
||||||
|
div_class = RECAPTCHA_DIV_CLASS_DEFAULT
|
||||||
|
return Markup(RECAPTCHA_TEMPLATE % (script, div_class, snippet))
|
||||||
|
|
||||||
|
def __call__(self, field, error=None, **kwargs):
|
||||||
|
"""Returns the recaptcha input HTML."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
public_key = current_app.config["RECAPTCHA_PUBLIC_KEY"]
|
||||||
|
except KeyError:
|
||||||
|
raise RuntimeError("RECAPTCHA_PUBLIC_KEY config not set") from None
|
||||||
|
|
||||||
|
return self.recaptcha_html(public_key)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
|
||||||
|
This version of the GNU Lesser General Public License incorporates
|
||||||
|
the terms and conditions of version 3 of the GNU General Public
|
||||||
|
License, supplemented by the additional permissions listed below.
|
||||||
|
|
||||||
|
0. Additional Definitions.
|
||||||
|
|
||||||
|
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||||
|
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
"The Library" refers to a covered work governed by this License,
|
||||||
|
other than an Application or a Combined Work as defined below.
|
||||||
|
|
||||||
|
An "Application" is any work that makes use of an interface provided
|
||||||
|
by the Library, but which is not otherwise based on the Library.
|
||||||
|
Defining a subclass of a class defined by the Library is deemed a mode
|
||||||
|
of using an interface provided by the Library.
|
||||||
|
|
||||||
|
A "Combined Work" is a work produced by combining or linking an
|
||||||
|
Application with the Library. The particular version of the Library
|
||||||
|
with which the Combined Work was made is also called the "Linked
|
||||||
|
Version".
|
||||||
|
|
||||||
|
The "Minimal Corresponding Source" for a Combined Work means the
|
||||||
|
Corresponding Source for the Combined Work, excluding any source code
|
||||||
|
for portions of the Combined Work that, considered in isolation, are
|
||||||
|
based on the Application, and not on the Linked Version.
|
||||||
|
|
||||||
|
The "Corresponding Application Code" for a Combined Work means the
|
||||||
|
object code and/or source code for the Application, including any data
|
||||||
|
and utility programs needed for reproducing the Combined Work from the
|
||||||
|
Application, but excluding the System Libraries of the Combined Work.
|
||||||
|
|
||||||
|
1. Exception to Section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
You may convey a covered work under sections 3 and 4 of this License
|
||||||
|
without being bound by section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
2. Conveying Modified Versions.
|
||||||
|
|
||||||
|
If you modify a copy of the Library, and, in your modifications, a
|
||||||
|
facility refers to a function or data to be supplied by an Application
|
||||||
|
that uses the facility (other than as an argument passed when the
|
||||||
|
facility is invoked), then you may convey a copy of the modified
|
||||||
|
version:
|
||||||
|
|
||||||
|
a) under this License, provided that you make a good faith effort to
|
||||||
|
ensure that, in the event an Application does not supply the
|
||||||
|
function or data, the facility still operates, and performs
|
||||||
|
whatever part of its purpose remains meaningful, or
|
||||||
|
|
||||||
|
b) under the GNU GPL, with none of the additional permissions of
|
||||||
|
this License applicable to that copy.
|
||||||
|
|
||||||
|
3. Object Code Incorporating Material from Library Header Files.
|
||||||
|
|
||||||
|
The object code form of an Application may incorporate material from
|
||||||
|
a header file that is part of the Library. You may convey such object
|
||||||
|
code under terms of your choice, provided that, if the incorporated
|
||||||
|
material is not limited to numerical parameters, data structure
|
||||||
|
layouts and accessors, or small macros, inline functions and templates
|
||||||
|
(ten or fewer lines in length), you do both of the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the object code that the
|
||||||
|
Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
4. Combined Works.
|
||||||
|
|
||||||
|
You may convey a Combined Work under terms of your choice that,
|
||||||
|
taken together, effectively do not restrict modification of the
|
||||||
|
portions of the Library contained in the Combined Work and reverse
|
||||||
|
engineering for debugging such modifications, if you also do each of
|
||||||
|
the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the Combined Work that
|
||||||
|
the Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
c) For a Combined Work that displays copyright notices during
|
||||||
|
execution, include the copyright notice for the Library among
|
||||||
|
these notices, as well as a reference directing the user to the
|
||||||
|
copies of the GNU GPL and this license document.
|
||||||
|
|
||||||
|
d) Do one of the following:
|
||||||
|
|
||||||
|
0) Convey the Minimal Corresponding Source under the terms of this
|
||||||
|
License, and the Corresponding Application Code in a form
|
||||||
|
suitable for, and under terms that permit, the user to
|
||||||
|
recombine or relink the Application with a modified version of
|
||||||
|
the Linked Version to produce a modified Combined Work, in the
|
||||||
|
manner specified by section 6 of the GNU GPL for conveying
|
||||||
|
Corresponding Source.
|
||||||
|
|
||||||
|
1) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (a) uses at run time
|
||||||
|
a copy of the Library already present on the user's computer
|
||||||
|
system, and (b) will operate properly with a modified version
|
||||||
|
of the Library that is interface-compatible with the Linked
|
||||||
|
Version.
|
||||||
|
|
||||||
|
e) Provide Installation Information, but only if you would otherwise
|
||||||
|
be required to provide such information under section 6 of the
|
||||||
|
GNU GPL, and only to the extent that such information is
|
||||||
|
necessary to install and execute a modified version of the
|
||||||
|
Combined Work produced by recombining or relinking the
|
||||||
|
Application with a modified version of the Linked Version. (If
|
||||||
|
you use option 4d0, the Installation Information must accompany
|
||||||
|
the Minimal Corresponding Source and Corresponding Application
|
||||||
|
Code. If you use option 4d1, you must provide the Installation
|
||||||
|
Information in the manner specified by section 6 of the GNU GPL
|
||||||
|
for conveying Corresponding Source.)
|
||||||
|
|
||||||
|
5. Combined Libraries.
|
||||||
|
|
||||||
|
You may place library facilities that are a work based on the
|
||||||
|
Library side by side in a single library together with other library
|
||||||
|
facilities that are not Applications and are not covered by this
|
||||||
|
License, and convey such a combined library under terms of your
|
||||||
|
choice, if you do both of the following:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work based
|
||||||
|
on the Library, uncombined with any other library facilities,
|
||||||
|
conveyed under the terms of this License.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library that part of it
|
||||||
|
is a work based on the Library, and explaining where to find the
|
||||||
|
accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
6. Revised Versions of the GNU Lesser General Public License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the GNU Lesser General Public License from time to time. Such new
|
||||||
|
versions will be similar in spirit to the present version, but may
|
||||||
|
differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Library as you received it specifies that a certain numbered version
|
||||||
|
of the GNU Lesser General Public License "or any later version"
|
||||||
|
applies to it, you have the option of following the terms and
|
||||||
|
conditions either of that published version or of any later version
|
||||||
|
published by the Free Software Foundation. If the Library as you
|
||||||
|
received it does not specify a version number of the GNU Lesser
|
||||||
|
General Public License, you may choose any version of the GNU Lesser
|
||||||
|
General Public License ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Library as you received it specifies that a proxy can decide
|
||||||
|
whether future versions of the GNU Lesser General Public License shall
|
||||||
|
apply, that proxy's public statement of acceptance of any version is
|
||||||
|
permanent authorization for you to choose that version for the
|
||||||
|
Library.
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
Metadata-Version: 2.1
|
||||||
|
Name: limiter
|
||||||
|
Version: 0.5.0
|
||||||
|
Summary: ⏲️ Easy rate limiting for Python. Rate limiting async and thread-safe decorators and context managers that use a token bucket algorithm.
|
||||||
|
Home-page: https://github.com/alexdelorenzo/limiter
|
||||||
|
Author: Alex DeLorenzo
|
||||||
|
License: LGPL-3.0
|
||||||
|
Keywords: rate-limit,rate,limit,token,bucket,token-bucket,token_bucket,tokenbucket,decorator,contextmanager,asynchronous,threadsafe,synchronous
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
License-File: LICENSE
|
||||||
|
Requires-Dist: strenum <0.5.0,>=0.4.7
|
||||||
|
Requires-Dist: token-bucket <0.4.0,>=0.3.0
|
||||||
|
|
||||||
|
# ⏲️ Easy rate limiting for Python
|
||||||
|
|
||||||
|
`limiter` makes it easy to add [rate limiting](https://en.wikipedia.org/wiki/Rate_limiting) to Python projects, using
|
||||||
|
a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm. `limiter` can provide Python projects and
|
||||||
|
scripts with:
|
||||||
|
|
||||||
|
- Rate limiting thread-safe [decorators](https://www.python.org/dev/peps/pep-0318/)
|
||||||
|
- Rate limiting async decorators
|
||||||
|
- Rate limiting thread-safe [context managers](https://www.python.org/dev/peps/pep-0343/)
|
||||||
|
- Rate
|
||||||
|
limiting [async context managers](https://www.python.org/dev/peps/pep-0492/#asynchronous-context-managers-and-async-with)
|
||||||
|
|
||||||
|
Here are some features and benefits of using `limiter`:
|
||||||
|
|
||||||
|
- Easily control burst and average request rates
|
||||||
|
- It
|
||||||
|
is [thread-safe, with no need for a timer thread](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm#Comparison_with_the_token_bucket)
|
||||||
|
- It adds [jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) to help with contention
|
||||||
|
- It has a simple API that takes advantage of Python's features, idioms
|
||||||
|
and [type hinting](https://www.python.org/dev/peps/pep-0483/)
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Here's an example of using a limiter as a decorator and context manager:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from limiter import Limiter
|
||||||
|
|
||||||
|
|
||||||
|
limit_downloads = Limiter(rate=2, capacity=5, consume=2)
|
||||||
|
|
||||||
|
|
||||||
|
@limit_downloads
|
||||||
|
async def download_image(url: str) -> bytes:
|
||||||
|
async with ClientSession() as session, session.get(url) as response:
|
||||||
|
return await response.read()
|
||||||
|
|
||||||
|
|
||||||
|
async def download_page(url: str) -> str:
|
||||||
|
async with (
|
||||||
|
ClientSession() as session,
|
||||||
|
limit_downloads,
|
||||||
|
session.get(url) as response
|
||||||
|
):
|
||||||
|
return await response.text()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
You can define limiters and use them dynamically across your project.
|
||||||
|
|
||||||
|
**Note**: If you're using Python version `3.9.x` or below, check
|
||||||
|
out [the documentation for version `0.2.0` of `limiter` here](https://github.com/alexdelorenzo/limiter/blob/master/README-0.2.0.md).
|
||||||
|
|
||||||
|
### `Limiter` instances
|
||||||
|
|
||||||
|
`Limiter` instances take `rate`, `capacity` and `consume` arguments.
|
||||||
|
|
||||||
|
- `rate` is the token replenishment rate per second. Tokens are automatically added every second.
|
||||||
|
- `consume` is the amount of tokens consumed from the token bucket upon successfully taking tokens from the bucket.
|
||||||
|
- `capacity` is the total amount of tokens the token bucket can hold. Token replenishment stops when this capacity is
|
||||||
|
reached.
|
||||||
|
|
||||||
|
### Limiting blocks of code
|
||||||
|
|
||||||
|
`limiter` can rate limit all Python callables, and limiters can be used as context managers.
|
||||||
|
|
||||||
|
You can define a limiter with a set refresh `rate` and total token `capacity`. You can set the amount of tokens to
|
||||||
|
consume dynamically with `consume`, and the `bucket` parameter sets the bucket to consume tokens from:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
from limiter import Limiter
|
||||||
|
|
||||||
|
|
||||||
|
REFRESH_RATE: int = 2
|
||||||
|
BURST_RATE: int = 3
|
||||||
|
MSG_BUCKET: str = 'messages'
|
||||||
|
|
||||||
|
limiter: Limiter = Limiter(rate=REFRESH_RATE, capacity=BURST_RATE)
|
||||||
|
limit_msgs: Limiter = limiter(bucket=MSG_BUCKET)
|
||||||
|
|
||||||
|
|
||||||
|
@limiter
|
||||||
|
def download_page(url: str) -> bytes:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@limiter(consume=2)
|
||||||
|
async def download_page(url: str) -> bytes:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def send_page(page: bytes):
|
||||||
|
with limiter(consume=1.5, bucket=MSG_BUCKET):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
async def send_page(page: bytes):
|
||||||
|
async with limit_msgs:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@limit_msgs(consume=3)
|
||||||
|
def send_email(to: str):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(to: str):
|
||||||
|
async with limiter(bucket=MSG_BUCKET):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
In the example above, both `limiter` and `limit_msgs` share the same limiter. The only difference is that `limit_msgs`
|
||||||
|
will take tokens from the `MSG_BUCKET` bucket by default.
|
||||||
|
|
||||||
|
```python3
|
||||||
|
assert limiter.limiter is limit_msgs.limiter
|
||||||
|
assert limiter.bucket != limit_msgs.bucket
|
||||||
|
assert limiter != limit_msgs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating new limiters
|
||||||
|
|
||||||
|
You can reuse existing limiters in your code, and you can create new limiters from the parameters of an existing limiter
|
||||||
|
using the `new()` method.
|
||||||
|
|
||||||
|
Or, you can define a new limiter entirely:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# you can reuse existing limiters
|
||||||
|
limit_downloads: Limiter = limiter(consume=2)
|
||||||
|
|
||||||
|
# you can use the settings from an existing limiter in a new limiter
|
||||||
|
limit_downloads: Limiter = limiter.new(consume=2)
|
||||||
|
|
||||||
|
# or you can simply define a new limiter
|
||||||
|
limit_downloads: Limiter = Limiter(REFRESH_RATE, BURST_RATE, consume=2)
|
||||||
|
|
||||||
|
|
||||||
|
@limit_downloads
|
||||||
|
def download_page(url: str) -> bytes:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@limit_downloads
|
||||||
|
async def download_page(url: str) -> bytes:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def download_image(url: str) -> bytes:
|
||||||
|
with limit_downloads:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
async def download_image(url: str) -> bytes:
|
||||||
|
async with limit_downloads:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Let's look at the difference between reusing an existing limiter, and creating new limiters with the `new()` method:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
limiter_a: Limiter = limiter(consume=2)
|
||||||
|
limiter_b: Limiter = limiter.new(consume=2)
|
||||||
|
limiter_c: Limiter = Limiter(REFRESH_RATE, BURST_RATE, consume=2)
|
||||||
|
|
||||||
|
assert limiter_a != limiter
|
||||||
|
assert limiter_a != limiter_b != limiter_c
|
||||||
|
|
||||||
|
assert limiter_a != limiter_b
|
||||||
|
assert limiter_a.limiter is limiter.limiter
|
||||||
|
assert limiter_a.limiter is not limiter_b.limiter
|
||||||
|
|
||||||
|
assert limiter_a.attrs == limiter_b.attrs == limiter_c.attrs
|
||||||
|
```
|
||||||
|
|
||||||
|
The only things that are equivalent between the three new limiters above are the limiters' attributes, like
|
||||||
|
the `rate`, `capacity`, and `consume` attributes.
|
||||||
|
|
||||||
|
### Creating anonymous, or single-use, limiters
|
||||||
|
|
||||||
|
You don't have to assign `Limiter` objects to variables. Anonymous limiters don't share a token bucket like named
|
||||||
|
limiters can. They work well when you don't have a reason to share a limiter between two or more blocks of code, and
|
||||||
|
when a limiter has a single or independent purpose.
|
||||||
|
|
||||||
|
`limiter`, after version `v0.3.0`, ships with a `limit` type alias for `Limiter`:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
from limiter import limit
|
||||||
|
|
||||||
|
|
||||||
|
@limit(capacity=2, consume=2)
|
||||||
|
async def send_message():
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_image():
|
||||||
|
async with limit(capacity=3) as limiter:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The above is equivalent to the below:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
from limiter import Limiter
|
||||||
|
|
||||||
|
|
||||||
|
@Limiter(capacity=2, consume=2)
|
||||||
|
async def send_message():
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_image():
|
||||||
|
async with Limiter(capacity=3) as limiter:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `limit` and `Limiter` are the same object:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
assert limit is Limiter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jitter
|
||||||
|
|
||||||
|
A `Limiter`'s `jitter` argument adds jitter to help with contention.
|
||||||
|
|
||||||
|
The value is in `units`, which is milliseconds by default, and can be any of these:
|
||||||
|
|
||||||
|
- `False`, to add no jitter. This is the default.
|
||||||
|
- `True`, to add a random amount of jitter.
|
||||||
|
- A number, to add a fixed amount of jitter.
|
||||||
|
- A `range` object, to add a random amount of jitter within the range.
|
||||||
|
- A `tuple` of two numbers, `start` and `stop`, to add a random amount of jitter between the two numbers.
|
||||||
|
- A `tuple` of three numbers: `start`, `stop` and `step`, to add jitter like you would with `range`.
|
||||||
|
|
||||||
|
For example, if you want to use a random amount of jitter between `0` and `100` milliseconds:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
limiter = Limiter(rate=2, capacity=5, consume=2, jitter=(0, 100))
|
||||||
|
limiter = Limiter(rate=2, capacity=5, consume=2, jitter=(0, 100, 1))
|
||||||
|
limiter = Limiter(rate=2, capacity=5, consume=2, jitter=range(0, 100))
|
||||||
|
limiter = Limiter(rate=2, capacity=5, consume=2, jitter=range(0, 100, 1))
|
||||||
|
```
|
||||||
|
|
||||||
|
All of the above are equivalent to each other in function.
|
||||||
|
|
||||||
|
You can also supply values for `jitter` when using decorators or context-managers:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
limiter = Limiter(rate=2, capacity=5, consume=2)
|
||||||
|
|
||||||
|
|
||||||
|
@limiter(jitter=range(0, 100))
|
||||||
|
def download_page(url: str) -> bytes:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
async def download_page(url: str) -> bytes:
|
||||||
|
async with limiter(jitter=(0, 100)):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
You can use the above to override default values of `jitter` in a `Limiter` instance.
|
||||||
|
|
||||||
|
|
||||||
|
To add a small amount of random jitter, supply `True` as the value:
|
||||||
|
```python3
|
||||||
|
limiter = Limiter(rate=2, capacity=5, consume=2, jitter=True)
|
||||||
|
|
||||||
|
# or
|
||||||
|
|
||||||
|
@limiter(jitter=True)
|
||||||
|
def download_page(url: str) -> bytes:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
To turn off jitter in a `Limiter` configured with jitter, you can supply `False` as the value:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
limiter = Limiter(rate=2, capacity=5, consume=2, jitter=range(10))
|
||||||
|
|
||||||
|
|
||||||
|
@limiter(jitter=False)
|
||||||
|
def download_page(url: str) -> bytes:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
async def download_page(url: str) -> bytes:
|
||||||
|
async with limiter(jitter=False):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create a new limiter with jitter turned off:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
limiter: Limiter = limiter.new(jitter=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Units
|
||||||
|
|
||||||
|
`units` is a number representing the amount of units in one second. The default value is `1000` for 1,000 milliseconds in one second.
|
||||||
|
|
||||||
|
Similar to `jitter`, `units` can be supplied at all the same call sites and constructors that `jitter` is accepted.
|
||||||
|
|
||||||
|
If you want to use a different unit than milliseconds, supply a different value for `units`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Python 3.10+ for versions `0.3.0` and up
|
||||||
|
- [Python 3.7+ for versions below `0.3.0`](https://github.com/alexdelorenzo/limiter/blob/master/README-0.2.0.md)
|
||||||
|
|
||||||
|
### Install via PyPI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ python3 -m pip install limiter
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See [`LICENSE`](/LICENSE). If you'd like to use this project with a different license, please get in touch.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
limiter-0.5.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
limiter-0.5.0.dist-info/LICENSE,sha256=46mU2C5kSwOnkqkw9XQAJlhBL2JAf1_uCD8lVcXyMRg,7652
|
||||||
|
limiter-0.5.0.dist-info/METADATA,sha256=MwITStcmPl2MY7At0JDbwhWhPk723uiQB-WrTbZN_FY,9660
|
||||||
|
limiter-0.5.0.dist-info/RECORD,,
|
||||||
|
limiter-0.5.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
limiter-0.5.0.dist-info/WHEEL,sha256=-G_t0oGuE7UD0DrSpVZnq1hHMBV9DD2XkS5v7XpmTnk,110
|
||||||
|
limiter-0.5.0.dist-info/top_level.txt,sha256=rqG3yt65wf_ybeha_4dH4LNZI8b__T9xih_yH7H1VA8,8
|
||||||
|
limiter-0.5.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
||||||
|
limiter/__init__.py,sha256=UQbgzz-eV_Mv_62-9Hjeq6SqYi4K1kMHjy_iOEK4LwQ,136
|
||||||
|
limiter/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
limiter/__pycache__/base.cpython-311.pyc,,
|
||||||
|
limiter/__pycache__/limiter.cpython-311.pyc,,
|
||||||
|
limiter/base.py,sha256=qSyc9PQT1Mg3ssteyYmmKOOTt7E2Yo3qoVTQsk8ZJkM,2339
|
||||||
|
limiter/limiter.py,sha256=oI8wsqHttd4Ja43PuqIBm5J5n5MqAwN4XaTY2E2WrhY,6681
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: bdist_wheel (0.42.0)
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py2-none-any
|
||||||
|
Tag: py3-none-any
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
limiter
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
|
from .limiter import *
|
||||||
|
|
||||||
|
|
||||||
|
limit: TypeAlias = Limiter # alias to create anonymous or single-use `Limiter`s
|
||||||
95
buffteks/lib/python3.11/site-packages/limiter/base.py
Normal file
95
buffteks/lib/python3.11/site-packages/limiter/base.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Final, TypeVar, ParamSpec, Callable, Awaitable
|
||||||
|
from random import random, randrange
|
||||||
|
|
||||||
|
from token_bucket import Limiter as TokenBucket, MemoryStorage
|
||||||
|
|
||||||
|
|
||||||
|
WAKE_UP: Final[int] = 0
|
||||||
|
|
||||||
|
|
||||||
|
P = ParamSpec('P')
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
Decoratable = Callable[P, T] | Callable[P, Awaitable[T]]
|
||||||
|
Decorated = Decoratable
|
||||||
|
Decorator = Callable[[Decoratable[P, T]], Decorated[P, T]]
|
||||||
|
|
||||||
|
Bucket = bytes
|
||||||
|
BucketName = Bucket | str
|
||||||
|
Num = int | float
|
||||||
|
Tokens = Num
|
||||||
|
Duration = Num
|
||||||
|
UnitsInSecond = Duration
|
||||||
|
JitterRange = range | tuple[int, int] | tuple[int, int, int]
|
||||||
|
Jitter = int | bool | JitterRange
|
||||||
|
|
||||||
|
|
||||||
|
CONSUME_TOKENS: Final[Tokens] = 1
|
||||||
|
RATE: Final[Tokens] = 2
|
||||||
|
CAPACITY: Final[Tokens] = 3
|
||||||
|
MS_IN_SEC: Final[UnitsInSecond] = 1000
|
||||||
|
|
||||||
|
DEFAULT_BUCKET: Final[Bucket] = b"default"
|
||||||
|
DEFAULT_JITTER: Final[Jitter] = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bucket(name: BucketName) -> Bucket:
|
||||||
|
match name:
|
||||||
|
case bytes():
|
||||||
|
return name
|
||||||
|
|
||||||
|
case str():
|
||||||
|
return name.encode()
|
||||||
|
|
||||||
|
raise TypeError('Name must be bytes or a bytes-encodable string.')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_limiter(rate: Tokens = RATE, capacity: Tokens = CAPACITY) -> TokenBucket:
|
||||||
|
"""
|
||||||
|
Returns TokenBucket object that implements a token-bucket algorithm.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return TokenBucket(rate, capacity, MemoryStorage())
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bucket_limiter(bucket: BucketName, limiter: 'Limiter') -> tuple[Bucket, TokenBucket]:
|
||||||
|
bucket: Bucket = _get_bucket(bucket)
|
||||||
|
|
||||||
|
if not isinstance(limiter, TokenBucket):
|
||||||
|
limiter: TokenBucket = limiter.limiter
|
||||||
|
|
||||||
|
return bucket, limiter
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sleep_duration(
|
||||||
|
consume: Tokens,
|
||||||
|
tokens: Tokens,
|
||||||
|
rate: Tokens,
|
||||||
|
jitter: Jitter = DEFAULT_JITTER,
|
||||||
|
units: UnitsInSecond = MS_IN_SEC
|
||||||
|
) -> Duration:
|
||||||
|
"""Increase contention by adding jitter to sleep duration"""
|
||||||
|
duration: Duration = (consume - tokens) / rate
|
||||||
|
|
||||||
|
match jitter:
|
||||||
|
case int() | float():
|
||||||
|
return duration - jitter
|
||||||
|
|
||||||
|
case bool() if jitter:
|
||||||
|
amount: Duration = random() / units
|
||||||
|
return duration - amount
|
||||||
|
|
||||||
|
case range():
|
||||||
|
amount: Duration = randrange(jitter.start, jitter.stop, jitter.step) / units
|
||||||
|
return duration - amount
|
||||||
|
|
||||||
|
case start, end:
|
||||||
|
amount: Duration = randrange(start, end) / units
|
||||||
|
return duration - amount
|
||||||
|
|
||||||
|
case start, end, step:
|
||||||
|
amount: Duration = randrange(start, end, step) / units
|
||||||
|
return duration - amount
|
||||||
|
|
||||||
|
return duration
|
||||||
258
buffteks/lib/python3.11/site-packages/limiter/limiter.py
Normal file
258
buffteks/lib/python3.11/site-packages/limiter/limiter.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import (
|
||||||
|
AsyncContextManager, ContextManager, Awaitable, TypedDict,
|
||||||
|
cast
|
||||||
|
)
|
||||||
|
from contextlib import (
|
||||||
|
AbstractContextManager, AbstractAsyncContextManager,
|
||||||
|
contextmanager, asynccontextmanager
|
||||||
|
)
|
||||||
|
from asyncio import sleep as aiosleep, iscoroutinefunction
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from functools import wraps
|
||||||
|
from enum import auto
|
||||||
|
from time import sleep
|
||||||
|
from abc import ABC
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from strenum import StrEnum # type: ignore
|
||||||
|
from token_bucket import Limiter as TokenBucket # type: ignore
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
MS_IN_SEC, UnitsInSecond, WAKE_UP, RATE, CAPACITY, CONSUME_TOKENS, DEFAULT_BUCKET,
|
||||||
|
Tokens, Decoratable, Decorated, Decorator, Jitter, P, T,
|
||||||
|
BucketName, _get_limiter, _get_bucket_limiter,
|
||||||
|
_get_sleep_duration, DEFAULT_JITTER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Attrs(TypedDict):
|
||||||
|
consume: Tokens
|
||||||
|
bucket: BucketName
|
||||||
|
|
||||||
|
limiter: TokenBucket
|
||||||
|
jitter: Jitter
|
||||||
|
units: UnitsInSecond
|
||||||
|
|
||||||
|
|
||||||
|
class LimiterBase(ABC):
|
||||||
|
consume: Tokens
|
||||||
|
bucket: BucketName
|
||||||
|
|
||||||
|
limiter: TokenBucket
|
||||||
|
jitter: Jitter
|
||||||
|
units: UnitsInSecond
|
||||||
|
|
||||||
|
|
||||||
|
class LimiterContextManager(
|
||||||
|
LimiterBase,
|
||||||
|
AbstractContextManager,
|
||||||
|
AbstractAsyncContextManager
|
||||||
|
):
|
||||||
|
def __enter__(self) -> ContextManager[Limiter]:
|
||||||
|
with limit_rate(self.limiter, self.consume, self.bucket, self.jitter, self.units) as limiter:
|
||||||
|
return limiter
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> AsyncContextManager[Limiter]:
|
||||||
|
async with async_limit_rate(self.limiter, self.consume, self.bucket, self.jitter, self.units) as limiter:
|
||||||
|
return limiter
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AttrName(StrEnum):
|
||||||
|
rate: str = auto()
|
||||||
|
capacity: str = auto()
|
||||||
|
|
||||||
|
consume: str = auto()
|
||||||
|
bucket: str = auto()
|
||||||
|
|
||||||
|
limiter: str = auto()
|
||||||
|
jitter: str = auto()
|
||||||
|
units: str = auto()
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Limiter(LimiterContextManager):
|
||||||
|
rate: Tokens = RATE
|
||||||
|
capacity: Tokens = CAPACITY
|
||||||
|
|
||||||
|
consume: Tokens | None = None
|
||||||
|
bucket: BucketName = DEFAULT_BUCKET
|
||||||
|
|
||||||
|
limiter: TokenBucket | None = None
|
||||||
|
jitter: Jitter = DEFAULT_JITTER
|
||||||
|
units: UnitsInSecond = MS_IN_SEC
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.limiter is None:
|
||||||
|
self.limiter = _get_limiter(self.rate, self.capacity)
|
||||||
|
|
||||||
|
if self.consume is None:
|
||||||
|
self.consume = CONSUME_TOKENS
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
func_or_consume: Decoratable[P, T] | Tokens | None = None,
|
||||||
|
bucket: BucketName | None = None,
|
||||||
|
jitter: Jitter | None = None,
|
||||||
|
units: UnitsInSecond | None = None,
|
||||||
|
**attrs: Attrs,
|
||||||
|
) -> Decorated[P, T] | Limiter:
|
||||||
|
if callable(func_or_consume):
|
||||||
|
func: Decoratable = cast(Decoratable, func_or_consume)
|
||||||
|
wrapper = limit_calls(self, self.consume, self.bucket)
|
||||||
|
return wrapper(func)
|
||||||
|
|
||||||
|
elif func_or_consume and not isinstance(func_or_consume, Tokens):
|
||||||
|
raise TypeError(f'First argument must be callable or {Tokens}')
|
||||||
|
|
||||||
|
if AttrName.rate in attrs or AttrName.capacity in attrs:
|
||||||
|
raise ValueError('Create a new limiter with the new() method or Limiter class')
|
||||||
|
|
||||||
|
consume: Tokens = cast(Tokens, func_or_consume)
|
||||||
|
new_attrs: Attrs = self.attrs
|
||||||
|
|
||||||
|
if consume:
|
||||||
|
new_attrs[AttrName.consume] = consume
|
||||||
|
|
||||||
|
if bucket:
|
||||||
|
new_attrs[AttrName.bucket] = bucket
|
||||||
|
|
||||||
|
if jitter:
|
||||||
|
new_attrs[AttrName.jitter] = jitter
|
||||||
|
|
||||||
|
if units:
|
||||||
|
new_attrs[AttrName.units] = units
|
||||||
|
|
||||||
|
new_attrs |= attrs
|
||||||
|
|
||||||
|
return Limiter(**new_attrs, limiter=self.limiter)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attrs(self) -> Attrs:
|
||||||
|
attrs = asdict(self)
|
||||||
|
attrs.pop(AttrName.limiter, None)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def new(self, **attrs: Attrs):
|
||||||
|
new_attrs = self.attrs | attrs
|
||||||
|
|
||||||
|
return Limiter(**new_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def limit_calls(
|
||||||
|
limiter: Limiter,
|
||||||
|
consume: Tokens = CONSUME_TOKENS,
|
||||||
|
bucket: BucketName = DEFAULT_BUCKET,
|
||||||
|
jitter: Jitter = DEFAULT_JITTER,
|
||||||
|
units: UnitsInSecond = MS_IN_SEC,
|
||||||
|
) -> Decorator[P, T]:
|
||||||
|
"""
|
||||||
|
Rate-limiting decorator for synchronous and asynchronous callables.
|
||||||
|
"""
|
||||||
|
lim_wrapper: Limiter = limiter
|
||||||
|
|
||||||
|
bucket, limiter = _get_bucket_limiter(bucket, limiter)
|
||||||
|
limiter: TokenBucket = cast(TokenBucket, limiter)
|
||||||
|
|
||||||
|
def decorator(func: Decoratable[P, T]) -> Decorated[P, T]:
|
||||||
|
if iscoroutinefunction(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def new_coroutine_func(*args: P.args, **kwargs: P.kwargs) -> Awaitable[T]:
|
||||||
|
async with async_limit_rate(limiter, consume, bucket, jitter, units):
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
new_coroutine_func.limiter = lim_wrapper
|
||||||
|
return new_coroutine_func
|
||||||
|
|
||||||
|
elif callable(func):
|
||||||
|
@wraps(func)
|
||||||
|
def new_func(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||||
|
with limit_rate(limiter, consume, bucket, jitter, units):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
new_func.limiter = lim_wrapper
|
||||||
|
return new_func
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError("Can only decorate callables and coroutine functions.")
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def async_limit_rate(
|
||||||
|
limiter: Limiter,
|
||||||
|
consume: Tokens = CONSUME_TOKENS,
|
||||||
|
bucket: BucketName = DEFAULT_BUCKET,
|
||||||
|
jitter: Jitter = DEFAULT_JITTER,
|
||||||
|
units: UnitsInSecond = MS_IN_SEC,
|
||||||
|
) -> AsyncContextManager[Limiter]:
|
||||||
|
"""
|
||||||
|
Rate-limiting asynchronous context manager.
|
||||||
|
"""
|
||||||
|
lim_wrapper: Limiter = limiter
|
||||||
|
|
||||||
|
bucket, limiter = _get_bucket_limiter(bucket, limiter)
|
||||||
|
limiter: TokenBucket = cast(TokenBucket, limiter)
|
||||||
|
|
||||||
|
# minimize attribute look ups in loop
|
||||||
|
get_tokens = limiter._storage.get_token_count
|
||||||
|
lim_consume = limiter.consume
|
||||||
|
rate = limiter._rate
|
||||||
|
|
||||||
|
while not lim_consume(bucket, consume):
|
||||||
|
tokens = get_tokens(bucket)
|
||||||
|
sleep_for = _get_sleep_duration(consume, tokens, rate, jitter, units)
|
||||||
|
|
||||||
|
if sleep_for <= WAKE_UP:
|
||||||
|
break
|
||||||
|
|
||||||
|
log.debug(f'Rate limit reached. Sleeping for {sleep_for}s.')
|
||||||
|
await aiosleep(sleep_for)
|
||||||
|
|
||||||
|
yield lim_wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def limit_rate(
|
||||||
|
limiter: Limiter,
|
||||||
|
consume: Tokens = CONSUME_TOKENS,
|
||||||
|
bucket: BucketName = DEFAULT_BUCKET,
|
||||||
|
jitter: Jitter = DEFAULT_JITTER,
|
||||||
|
units: UnitsInSecond = MS_IN_SEC,
|
||||||
|
) -> ContextManager[Limiter]:
|
||||||
|
"""
|
||||||
|
Thread-safe rate-limiting context manager.
|
||||||
|
"""
|
||||||
|
lim_wrapper: Limiter = limiter
|
||||||
|
|
||||||
|
bucket, limiter = _get_bucket_limiter(bucket, limiter)
|
||||||
|
limiter: TokenBucket = cast(TokenBucket, limiter)
|
||||||
|
|
||||||
|
# minimize attribute look ups in loop
|
||||||
|
get_tokens = limiter._storage.get_token_count
|
||||||
|
lim_consume = limiter.consume
|
||||||
|
rate = limiter._rate
|
||||||
|
|
||||||
|
while not lim_consume(bucket, consume):
|
||||||
|
tokens = get_tokens(bucket)
|
||||||
|
sleep_for = _get_sleep_duration(consume, tokens, rate, jitter, units)
|
||||||
|
|
||||||
|
if sleep_for <= WAKE_UP:
|
||||||
|
break
|
||||||
|
|
||||||
|
log.debug(f'Rate limit reached. Sleeping for {sleep_for}s.')
|
||||||
|
sleep(sleep_for)
|
||||||
|
|
||||||
|
yield lim_wrapper
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: limits
|
||||||
|
Version: 5.6.0
|
||||||
|
Summary: Rate limiting utilities
|
||||||
|
Project-URL: Homepage, https://limits.readthedocs.org
|
||||||
|
Project-URL: Source, https://github.com/alisaifee/limits
|
||||||
|
Project-URL: Documentation, https://limits.readthedocs.org
|
||||||
|
Author-email: Ali-Akber Saifee <ali@indydevs.org>
|
||||||
|
Maintainer-email: Ali-Akber Saifee <ali@indydevs.org>
|
||||||
|
License-Expression: MIT
|
||||||
|
License-File: LICENSE.txt
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: License :: OSI Approved :: MIT License
|
||||||
|
Classifier: Operating System :: MacOS
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Operating System :: POSIX :: Linux
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||||
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Requires-Dist: deprecated>=1.2
|
||||||
|
Requires-Dist: packaging>=21
|
||||||
|
Requires-Dist: typing-extensions
|
||||||
|
Provides-Extra: async-memcached
|
||||||
|
Requires-Dist: memcachio>=0.3; extra == 'async-memcached'
|
||||||
|
Provides-Extra: async-mongodb
|
||||||
|
Requires-Dist: motor<4,>=3; extra == 'async-mongodb'
|
||||||
|
Provides-Extra: async-redis
|
||||||
|
Requires-Dist: coredis<6,>=3.4.0; extra == 'async-redis'
|
||||||
|
Provides-Extra: async-valkey
|
||||||
|
Requires-Dist: valkey>=6; extra == 'async-valkey'
|
||||||
|
Provides-Extra: memcached
|
||||||
|
Requires-Dist: pymemcache<5.0.0,>3; extra == 'memcached'
|
||||||
|
Provides-Extra: mongodb
|
||||||
|
Requires-Dist: pymongo<5,>4.1; extra == 'mongodb'
|
||||||
|
Provides-Extra: redis
|
||||||
|
Requires-Dist: redis!=4.5.2,!=4.5.3,<7.0.0,>3; extra == 'redis'
|
||||||
|
Provides-Extra: rediscluster
|
||||||
|
Requires-Dist: redis!=4.5.2,!=4.5.3,>=4.2.0; extra == 'rediscluster'
|
||||||
|
Provides-Extra: valkey
|
||||||
|
Requires-Dist: valkey>=6; extra == 'valkey'
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
|
||||||
|
.. |ci| image:: https://github.com/alisaifee/limits/actions/workflows/main.yml/badge.svg?branch=master
|
||||||
|
:target: https://github.com/alisaifee/limits/actions?query=branch%3Amaster+workflow%3ACI
|
||||||
|
.. |codecov| image:: https://codecov.io/gh/alisaifee/limits/branch/master/graph/badge.svg
|
||||||
|
:target: https://codecov.io/gh/alisaifee/limits
|
||||||
|
.. |pypi| image:: https://img.shields.io/pypi/v/limits.svg?style=flat-square
|
||||||
|
:target: https://pypi.python.org/pypi/limits
|
||||||
|
.. |pypi-versions| image:: https://img.shields.io/pypi/pyversions/limits?style=flat-square
|
||||||
|
:target: https://pypi.python.org/pypi/limits
|
||||||
|
.. |license| image:: https://img.shields.io/pypi/l/limits.svg?style=flat-square
|
||||||
|
:target: https://pypi.python.org/pypi/limits
|
||||||
|
.. |docs| image:: https://readthedocs.org/projects/limits/badge/?version=latest
|
||||||
|
:target: https://limits.readthedocs.org
|
||||||
|
|
||||||
|
######
|
||||||
|
limits
|
||||||
|
######
|
||||||
|
|docs| |ci| |codecov| |pypi| |pypi-versions| |license|
|
||||||
|
|
||||||
|
|
||||||
|
**limits** is a python library for rate limiting via multiple strategies
|
||||||
|
with commonly used storage backends (Redis, Memcached & MongoDB).
|
||||||
|
|
||||||
|
The library provides identical APIs for use in sync and
|
||||||
|
`async <https://limits.readthedocs.io/en/stable/async.html>`_ codebases.
|
||||||
|
|
||||||
|
|
||||||
|
Supported Strategies
|
||||||
|
====================
|
||||||
|
|
||||||
|
All strategies support the follow methods:
|
||||||
|
|
||||||
|
- `hit <https://limits.readthedocs.io/en/stable/api.html#limits.strategies.RateLimiter.hit>`_: consume a request.
|
||||||
|
- `test <https://limits.readthedocs.io/en/stable/api.html#limits.strategies.RateLimiter.test>`_: check if a request is allowed.
|
||||||
|
- `get_window_stats <https://limits.readthedocs.io/en/stable/api.html#limits.strategies.RateLimiter.get_window_stats>`_: retrieve remaining quota and reset time.
|
||||||
|
|
||||||
|
Fixed Window
|
||||||
|
------------
|
||||||
|
`Fixed Window <https://limits.readthedocs.io/en/latest/strategies.html#fixed-window>`_
|
||||||
|
|
||||||
|
This strategy is the most memory‑efficient because it uses a single counter per resource and
|
||||||
|
rate limit. When the first request arrives, a window is started for a fixed duration
|
||||||
|
(e.g., for a rate limit of 10 requests per minute the window expires in 60 seconds from the first request).
|
||||||
|
All requests in that window increment the counter and when the window expires, the counter resets.
|
||||||
|
|
||||||
|
Burst traffic that bypasses the rate limit may occur at window boundaries.
|
||||||
|
|
||||||
|
For example, with a rate limit of 10 requests per minute:
|
||||||
|
|
||||||
|
- At **00:00:45**, the first request arrives, starting a window from **00:00:45** to **00:01:45**.
|
||||||
|
- All requests between **00:00:45** and **00:01:45** count toward the limit.
|
||||||
|
- If 10 requests occur at any time in that window, any further request before **00:01:45** is rejected.
|
||||||
|
- At **00:01:45**, the counter resets and a new window starts which would allow 10 requests
|
||||||
|
until **00:02:45**.
|
||||||
|
|
||||||
|
Moving Window
|
||||||
|
-------------
|
||||||
|
`Moving Window <https://limits.readthedocs.io/en/latest/strategies.html#moving-window>`_
|
||||||
|
|
||||||
|
This strategy adds each request’s timestamp to a log if the ``nth`` oldest entry (where ``n``
|
||||||
|
is the limit) is either not present or is older than the duration of the window (for example with a rate limit of
|
||||||
|
``10 requests per minute`` if there are either less than 10 entries or the 10th oldest entry is at least
|
||||||
|
60 seconds old). Upon adding a new entry to the log "expired" entries are truncated.
|
||||||
|
|
||||||
|
For example, with a rate limit of 10 requests per minute:
|
||||||
|
|
||||||
|
- At **00:00:10**, a client sends 1 requests which are allowed.
|
||||||
|
- At **00:00:20**, a client sends 2 requests which are allowed.
|
||||||
|
- At **00:00:30**, the client sends 4 requests which are allowed.
|
||||||
|
- At **00:00:50**, the client sends 3 requests which are allowed (total = 10).
|
||||||
|
- At **00:01:11**, the client sends 1 request. The strategy checks the timestamp of the
|
||||||
|
10th oldest entry (**00:00:10**) which is now 61 seconds old and thus expired. The request
|
||||||
|
is allowed.
|
||||||
|
- At **00:01:12**, the client sends 1 request. The 10th oldest entry's timestamp is **00:00:20**
|
||||||
|
which is only 52 seconds old. The request is rejected.
|
||||||
|
|
||||||
|
Sliding Window Counter
|
||||||
|
------------------------
|
||||||
|
`Sliding Window Counter <https://limits.readthedocs.io/en/latest/strategies.html#sliding-window-counter>`_
|
||||||
|
|
||||||
|
This strategy approximates the moving window while using less memory by maintaining
|
||||||
|
two counters:
|
||||||
|
|
||||||
|
- **Current bucket:** counts requests in the ongoing period.
|
||||||
|
- **Previous bucket:** counts requests in the immediately preceding period.
|
||||||
|
|
||||||
|
When a request arrives, the effective request count is calculated as::
|
||||||
|
|
||||||
|
weighted_count = current_count + floor(previous_count * weight)
|
||||||
|
|
||||||
|
The weight is based on how much time has elapsed in the current bucket::
|
||||||
|
|
||||||
|
weight = (bucket_duration - elapsed_time) / bucket_duration
|
||||||
|
|
||||||
|
If ``weighted_count`` is below the limit, the request is allowed.
|
||||||
|
|
||||||
|
For example, with a rate limit of 10 requests per minute:
|
||||||
|
|
||||||
|
Assume:
|
||||||
|
|
||||||
|
- The current bucket (spanning **00:01:00** to **00:02:00**) has 8 hits.
|
||||||
|
- The previous bucket (spanning **00:00:00** to **00:01:00**) has 4 hits.
|
||||||
|
|
||||||
|
Scenario 1:
|
||||||
|
|
||||||
|
- A new request arrives at **00:01:30**, 30 seconds into the current bucket.
|
||||||
|
- ``weight = (60 - 30) / 60 = 0.5``.
|
||||||
|
- ``weighted_count = floor(8 + (4 * 0.5)) = floor(8 + 2) = 10``.
|
||||||
|
- Since the weighted count equals the limit, the request is rejected.
|
||||||
|
|
||||||
|
Scenario 2:
|
||||||
|
|
||||||
|
- A new request arrives at **00:01:40**, 40 seconds into the current bucket.
|
||||||
|
- ``weight = (60 - 40) / 60 ≈ 0.33``.
|
||||||
|
- ``weighted_count = floor(8 + (4 * 0.33)) = floor(8 + 1.32) = 9``.
|
||||||
|
- Since the weighted count is below the limit, the request is allowed.
|
||||||
|
|
||||||
|
|
||||||
|
Storage backends
|
||||||
|
================
|
||||||
|
|
||||||
|
- `Redis <https://limits.readthedocs.io/en/latest/storage.html#redis-storage>`_
|
||||||
|
- `Memcached <https://limits.readthedocs.io/en/latest/storage.html#memcached-storage>`_
|
||||||
|
- `MongoDB <https://limits.readthedocs.io/en/latest/storage.html#mongodb-storage>`_
|
||||||
|
- `In-Memory <https://limits.readthedocs.io/en/latest/storage.html#in-memory-storage>`_
|
||||||
|
|
||||||
|
Dive right in
|
||||||
|
=============
|
||||||
|
|
||||||
|
Initialize the storage backend
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from limits import storage
|
||||||
|
backend = storage.MemoryStorage()
|
||||||
|
# or memcached
|
||||||
|
backend = storage.MemcachedStorage("memcached://localhost:11211")
|
||||||
|
# or redis
|
||||||
|
backend = storage.RedisStorage("redis://localhost:6379")
|
||||||
|
# or mongodb
|
||||||
|
backend = storage.MongoDbStorage("mongodb://localhost:27017")
|
||||||
|
# or use the factory
|
||||||
|
storage_uri = "memcached://localhost:11211"
|
||||||
|
backend = storage.storage_from_string(storage_uri)
|
||||||
|
|
||||||
|
Initialize a rate limiter with a strategy
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from limits import strategies
|
||||||
|
strategy = strategies.MovingWindowRateLimiter(backend)
|
||||||
|
# or fixed window
|
||||||
|
strategy = strategies.FixedWindowRateLimiter(backend)
|
||||||
|
# or sliding window
|
||||||
|
strategy = strategies.SlidingWindowCounterRateLimiter(backend)
|
||||||
|
|
||||||
|
|
||||||
|
Initialize a rate limit
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from limits import parse
|
||||||
|
one_per_minute = parse("1/minute")
|
||||||
|
|
||||||
|
Initialize a rate limit explicitly
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from limits import RateLimitItemPerSecond
|
||||||
|
one_per_second = RateLimitItemPerSecond(1, 1)
|
||||||
|
|
||||||
|
Test the limits
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import time
|
||||||
|
assert True == strategy.hit(one_per_minute, "test_namespace", "foo")
|
||||||
|
assert False == strategy.hit(one_per_minute, "test_namespace", "foo")
|
||||||
|
assert True == strategy.hit(one_per_minute, "test_namespace", "bar")
|
||||||
|
|
||||||
|
assert True == strategy.hit(one_per_second, "test_namespace", "foo")
|
||||||
|
assert False == strategy.hit(one_per_second, "test_namespace", "foo")
|
||||||
|
time.sleep(1)
|
||||||
|
assert True == strategy.hit(one_per_second, "test_namespace", "foo")
|
||||||
|
|
||||||
|
Check specific limits without hitting them
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
assert True == strategy.hit(one_per_second, "test_namespace", "foo")
|
||||||
|
while not strategy.test(one_per_second, "test_namespace", "foo"):
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert True == strategy.hit(one_per_second, "test_namespace", "foo")
|
||||||
|
|
||||||
|
Query available capacity and reset time for a limit
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
assert True == strategy.hit(one_per_minute, "test_namespace", "foo")
|
||||||
|
window = strategy.get_window_stats(one_per_minute, "test_namespace", "foo")
|
||||||
|
assert window.remaining == 0
|
||||||
|
assert False == strategy.hit(one_per_minute, "test_namespace", "foo")
|
||||||
|
time.sleep(window.reset_time - time.time())
|
||||||
|
assert True == strategy.hit(one_per_minute, "test_namespace", "foo")
|
||||||
|
|
||||||
|
|
||||||
|
Links
|
||||||
|
=====
|
||||||
|
|
||||||
|
* `Documentation <http://limits.readthedocs.org/en/latest>`_
|
||||||
|
* `Benchmarks <http://limits.readthedocs.org/en/latest/performance.html>`_
|
||||||
|
* `Changelog <http://limits.readthedocs.org/en/stable/changelog.html>`_
|
||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
limits-5.6.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
limits-5.6.0.dist-info/METADATA,sha256=AnwByiIGfXg-bL9FEzNEZ82RG2OXhbzkCMds8DZtob4,10357
|
||||||
|
limits-5.6.0.dist-info/RECORD,,
|
||||||
|
limits-5.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
||||||
|
limits-5.6.0.dist-info/licenses/LICENSE.txt,sha256=T6i7kq7F5gIPfcno9FCxU5Hcwm22Bjq0uHZV3ElcjsQ,1061
|
||||||
|
limits/__init__.py,sha256=o797RFCrvTx-9XGu8tsQYH5ZOtX-UVh6xwq_eatc_AU,734
|
||||||
|
limits/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
limits/__pycache__/_version.cpython-311.pyc,,
|
||||||
|
limits/__pycache__/errors.cpython-311.pyc,,
|
||||||
|
limits/__pycache__/limits.cpython-311.pyc,,
|
||||||
|
limits/__pycache__/strategies.cpython-311.pyc,,
|
||||||
|
limits/__pycache__/typing.cpython-311.pyc,,
|
||||||
|
limits/__pycache__/util.cpython-311.pyc,,
|
||||||
|
limits/_version.py,sha256=3ucixGE1aqqT6_wVh9iJlYsogjPN83_jiyXCqsGzfqU,704
|
||||||
|
limits/_version.pyi,sha256=Y25n44pyE3vp92MiABKrcK3IWRyQ1JG1rZ4Ufqy2nC0,17
|
||||||
|
limits/aio/__init__.py,sha256=yxvWb_ZmV245Hg2LqD365WC5IDllcGDMw6udJ1jNp1g,118
|
||||||
|
limits/aio/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
limits/aio/__pycache__/strategies.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/__init__.py,sha256=vKeArUnN1ld_0mQOBBZPCjaQgM5xI1GBPM7_F2Ydz5c,646
|
||||||
|
limits/aio/storage/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/__pycache__/base.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/__pycache__/memory.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/__pycache__/mongodb.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/base.py,sha256=56UyNz3I3J-4pQecjsaCK4pUC4L3R_9GzDnutdTrfKs,6706
|
||||||
|
limits/aio/storage/memcached/__init__.py,sha256=SjAEgxC6hPjobtyTf7tq3vThPMMbS4lGdtTo5kvoz64,6885
|
||||||
|
limits/aio/storage/memcached/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/memcached/__pycache__/bridge.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/memcached/__pycache__/emcache.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/memcached/__pycache__/memcachio.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/memcached/bridge.py,sha256=3CEruS6LvZWDQPGPLlwY4hemy6oN0WWduUE7t8vyXBI,2017
|
||||||
|
limits/aio/storage/memcached/emcache.py,sha256=J01jP-Udd2fLgamCh2CX9NEIvhN8eZVTzUok096Bbe4,3833
|
||||||
|
limits/aio/storage/memcached/memcachio.py,sha256=OoGVqOVG0pVX2McFeTGQ_AbiqQUu_FYwWItpQMtNV7g,3491
|
||||||
|
limits/aio/storage/memory.py,sha256=-U_GWPWmR77Hzi1Oa1_L1WjiAlROTS8PNG8PROAm13c,9842
|
||||||
|
limits/aio/storage/mongodb.py,sha256=0kwDyivA53ZIOUH4DNnCjVG3olLJqAWhXctjPrnHUp0,19252
|
||||||
|
limits/aio/storage/redis/__init__.py,sha256=RjGus6rj-RhUR4eqTcnpxgicCt_rPtwFkC_SmbKfoqQ,15032
|
||||||
|
limits/aio/storage/redis/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/redis/__pycache__/bridge.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/redis/__pycache__/coredis.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/redis/__pycache__/redispy.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/redis/__pycache__/valkey.cpython-311.pyc,,
|
||||||
|
limits/aio/storage/redis/bridge.py,sha256=tz6WGViOqIm81hjGPUOBlz-Qw0tSB71NIttn7Xb5lok,3189
|
||||||
|
limits/aio/storage/redis/coredis.py,sha256=IzfEyXBvQbr4QUWML9xAd87a2aHCvglOBEjAg-Vq4z0,7420
|
||||||
|
limits/aio/storage/redis/redispy.py,sha256=HS1H6E9g0dP3G-8tSUILIFoc8JWpeRQOiBxcpL3I0gM,8310
|
||||||
|
limits/aio/storage/redis/valkey.py,sha256=f_-HPZhzNspywGybMNIL0F5uDZk76v8_K9wuC5ZeKhc,248
|
||||||
|
limits/aio/strategies.py,sha256=ip7NJ_6FvEtICr90tesayaXcsqrmpG7VlC3PwxbfiVQ,10736
|
||||||
|
limits/errors.py,sha256=s1el9Vg0ly-z92guvnvYNgKi3_aVqpiw_sufemiLLTI,662
|
||||||
|
limits/limits.py,sha256=EztiGCXBVwIqNtps77HiW6vLlMO93wCh7mu5W7BuhwI,5011
|
||||||
|
limits/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
limits/resources/redis/lua_scripts/acquire_moving_window.lua,sha256=Vz0HkI_bSFLW668lEVw8paKlTLEuU4jZk1fpdSuz3zg,594
|
||||||
|
limits/resources/redis/lua_scripts/acquire_sliding_window.lua,sha256=OhVI1MAN_gT92P6r-2CEmvy1yvQVjYCCZxWIxfXYceY,1329
|
||||||
|
limits/resources/redis/lua_scripts/clear_keys.lua,sha256=zU0cVfLGmapRQF9x9u0GclapM_IB2pJLszNzVQ1QRK4,184
|
||||||
|
limits/resources/redis/lua_scripts/incr_expire.lua,sha256=Uq9NcrrcDI-F87TDAJexoSJn2SDgeXIUEYozCp9S3oA,195
|
||||||
|
limits/resources/redis/lua_scripts/moving_window.lua,sha256=zlieQwfET0BC7sxpfiOuzPa1wwmrwWLy7IF8LxNa_Lw,717
|
||||||
|
limits/resources/redis/lua_scripts/sliding_window.lua,sha256=qG3Yg30Dq54QpRUcR9AOrKQ5bdJiaYpCacTm6Kxblvc,713
|
||||||
|
limits/storage/__init__.py,sha256=9iNxIlwzLQw2d54EcMa2LBJ47wiWCPOnHgn6ddqKkDI,2652
|
||||||
|
limits/storage/__pycache__/__init__.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/base.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/memcached.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/memory.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/mongodb.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/redis.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/redis_cluster.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/redis_sentinel.cpython-311.pyc,,
|
||||||
|
limits/storage/__pycache__/registry.cpython-311.pyc,,
|
||||||
|
limits/storage/base.py,sha256=QFVhOS8VdR7PDhaYMSc77SLg8yaGm0PCNNrMu4ZamfY,7264
|
||||||
|
limits/storage/memcached.py,sha256=AzT3vz-MnkFxS0mF3C0QjGPzCnmUt29qTnuOKhKVKYI,10455
|
||||||
|
limits/storage/memory.py,sha256=4W8hWIEzwQpoh1z0LcfwuP6DqeFoVuOEM2u8WpZkfdQ,8957
|
||||||
|
limits/storage/mongodb.py,sha256=Cg_Vj33N7Ozxdmq7RGMCerg1XuVOhRAU7eusfhiSZBc,18170
|
||||||
|
limits/storage/redis.py,sha256=zTwxV5qosxGBTrkZmD4UWQdvavDbWpYHXY7H3hXH-Sw,10791
|
||||||
|
limits/storage/redis_cluster.py,sha256=GkL8GCQFfxDriMzsPMkaj6pMEX5FvQXYpUtXLY5q8fQ,4621
|
||||||
|
limits/storage/redis_sentinel.py,sha256=OSb61DxgUxMgXSIjaM_pF5-entD8XntD56xt0rFu89k,4479
|
||||||
|
limits/storage/registry.py,sha256=CxSaDBGR5aBJPFAIsfX9axCnbcThN3Bu-EH4wHrXtu8,650
|
||||||
|
limits/strategies.py,sha256=Q03NTAyADtwMalhRkOSdk6UE1gVfVt5n258xVyA481o,10732
|
||||||
|
limits/typing.py,sha256=pVt5D23MhQSUGqi0MBG5FCSqDwta2ygu18BpKvJFxow,3283
|
||||||
|
limits/util.py,sha256=283O2aXnN7DmaqjTeTiF-KYn5wVbnpXJ8vb-6LvY5lY,5983
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: hatchling 1.27.0
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Copyright (c) 2023 Ali-Akber Saifee
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
35
buffteks/lib/python3.11/site-packages/limits/__init__.py
Normal file
35
buffteks/lib/python3.11/site-packages/limits/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Rate limiting with commonly used storage backends
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import _version, aio, storage, strategies
|
||||||
|
from .limits import (
|
||||||
|
RateLimitItem,
|
||||||
|
RateLimitItemPerDay,
|
||||||
|
RateLimitItemPerHour,
|
||||||
|
RateLimitItemPerMinute,
|
||||||
|
RateLimitItemPerMonth,
|
||||||
|
RateLimitItemPerSecond,
|
||||||
|
RateLimitItemPerYear,
|
||||||
|
)
|
||||||
|
from .util import WindowStats, parse, parse_many
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RateLimitItem",
|
||||||
|
"RateLimitItemPerDay",
|
||||||
|
"RateLimitItemPerHour",
|
||||||
|
"RateLimitItemPerMinute",
|
||||||
|
"RateLimitItemPerMonth",
|
||||||
|
"RateLimitItemPerSecond",
|
||||||
|
"RateLimitItemPerYear",
|
||||||
|
"WindowStats",
|
||||||
|
"aio",
|
||||||
|
"parse",
|
||||||
|
"parse_many",
|
||||||
|
"storage",
|
||||||
|
"strategies",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = _version.__version__
|
||||||
34
buffteks/lib/python3.11/site-packages/limits/_version.py
Normal file
34
buffteks/lib/python3.11/site-packages/limits/_version.py
Normal 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 = '5.6.0'
|
||||||
|
__version_tuple__ = version_tuple = (5, 6, 0)
|
||||||
|
|
||||||
|
__commit_id__ = commit_id = None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__: str
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import storage, strategies
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"storage",
|
||||||
|
"strategies",
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Implementations of storage backends to be used with
|
||||||
|
:class:`limits.aio.strategies.RateLimiter` strategies
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import MovingWindowSupport, SlidingWindowCounterSupport, Storage
|
||||||
|
from .memcached import MemcachedStorage
|
||||||
|
from .memory import MemoryStorage
|
||||||
|
from .mongodb import MongoDBStorage
|
||||||
|
from .redis import RedisClusterStorage, RedisSentinelStorage, RedisStorage
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MemcachedStorage",
|
||||||
|
"MemoryStorage",
|
||||||
|
"MongoDBStorage",
|
||||||
|
"MovingWindowSupport",
|
||||||
|
"RedisClusterStorage",
|
||||||
|
"RedisSentinelStorage",
|
||||||
|
"RedisStorage",
|
||||||
|
"SlidingWindowCounterSupport",
|
||||||
|
"Storage",
|
||||||
|
]
|
||||||
234
buffteks/lib/python3.11/site-packages/limits/aio/storage/base.py
Normal file
234
buffteks/lib/python3.11/site-packages/limits/aio/storage/base.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from deprecated.sphinx import versionadded
|
||||||
|
|
||||||
|
from limits import errors
|
||||||
|
from limits.storage.registry import StorageRegistry
|
||||||
|
from limits.typing import (
|
||||||
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
P,
|
||||||
|
R,
|
||||||
|
cast,
|
||||||
|
)
|
||||||
|
from limits.util import LazyDependency
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_errors(
|
||||||
|
fn: Callable[P, Awaitable[R]],
|
||||||
|
) -> Callable[P, Awaitable[R]]:
|
||||||
|
@functools.wraps(fn)
|
||||||
|
async def inner(*args: P.args, **kwargs: P.kwargs) -> R: # type: ignore[misc]
|
||||||
|
instance = cast(Storage, args[0])
|
||||||
|
try:
|
||||||
|
return await fn(*args, **kwargs)
|
||||||
|
except instance.base_exceptions as exc:
|
||||||
|
if instance.wrap_exceptions:
|
||||||
|
raise errors.StorageError(exc) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="2.1")
|
||||||
|
class Storage(LazyDependency, metaclass=StorageRegistry):
|
||||||
|
"""
|
||||||
|
Base class to extend when implementing an async storage backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STORAGE_SCHEME: list[str] | None
|
||||||
|
"""The storage schemes to register against this implementation"""
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs: Any) -> None: # type:ignore[explicit-any]
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
for method in {
|
||||||
|
"incr",
|
||||||
|
"get",
|
||||||
|
"get_expiry",
|
||||||
|
"check",
|
||||||
|
"reset",
|
||||||
|
"clear",
|
||||||
|
}:
|
||||||
|
setattr(cls, method, _wrap_errors(getattr(cls, method)))
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str | None = None,
|
||||||
|
wrap_exceptions: bool = False,
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
||||||
|
:exc:`limits.errors.StorageError` before raising it.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.wrap_exceptions = wrap_exceptions
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def base_exceptions(self) -> type[Exception] | tuple[type[Exception], ...]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def incr(self, key: str, expiry: int, amount: int = 1) -> int:
|
||||||
|
"""
|
||||||
|
increments the counter for a given rate limit key
|
||||||
|
|
||||||
|
:param key: the key to increment
|
||||||
|
:param expiry: amount in seconds for the key to expire in
|
||||||
|
:param amount: the number to increment by
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the counter value for
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the expiry for
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
check if storage is healthy
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
"""
|
||||||
|
reset storage to clear limits
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
"""
|
||||||
|
resets the rate limit key
|
||||||
|
|
||||||
|
:param key: the key to clear rate limits for
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class MovingWindowSupport(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for async storages that support
|
||||||
|
the :ref:`strategies:moving window` strategy
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
||||||
|
for method in {
|
||||||
|
"acquire_entry",
|
||||||
|
"get_moving_window",
|
||||||
|
}:
|
||||||
|
setattr(
|
||||||
|
cls,
|
||||||
|
method,
|
||||||
|
_wrap_errors(getattr(cls, method)),
|
||||||
|
)
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def acquire_entry(
|
||||||
|
self, key: str, limit: int, expiry: int, amount: int = 1
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
:param key: rate limit key to acquire an entry in
|
||||||
|
:param limit: amount of entries allowed
|
||||||
|
:param expiry: expiry of the entry
|
||||||
|
:param amount: the number of entries to acquire
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_moving_window(
|
||||||
|
self, key: str, limit: int, expiry: int
|
||||||
|
) -> tuple[float, int]:
|
||||||
|
"""
|
||||||
|
returns the starting point and the number of entries in the moving
|
||||||
|
window
|
||||||
|
|
||||||
|
:param key: rate limit key
|
||||||
|
:param expiry: expiry of entry
|
||||||
|
:return: (start of window, number of acquired entries)
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowCounterSupport(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for async storages that support
|
||||||
|
the :ref:`strategies:sliding window counter` strategy
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
||||||
|
for method in {
|
||||||
|
"acquire_sliding_window_entry",
|
||||||
|
"get_sliding_window",
|
||||||
|
"clear_sliding_window",
|
||||||
|
}:
|
||||||
|
setattr(
|
||||||
|
cls,
|
||||||
|
method,
|
||||||
|
_wrap_errors(getattr(cls, method)),
|
||||||
|
)
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Acquire an entry if the weighted count of the current and previous
|
||||||
|
windows is less than or equal to the limit
|
||||||
|
|
||||||
|
:param key: rate limit key to acquire an entry in
|
||||||
|
:param limit: amount of entries allowed
|
||||||
|
:param expiry: expiry of the entry
|
||||||
|
:param amount: the number of entries to acquire
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
"""
|
||||||
|
Return the previous and current window information.
|
||||||
|
|
||||||
|
:param key: the rate limit key
|
||||||
|
:param expiry: the rate limit expiry, needed to compute the key in some implementations
|
||||||
|
:return: a tuple of (int, float, int, float) with the following information:
|
||||||
|
- previous window counter
|
||||||
|
- previous window TTL
|
||||||
|
- current window counter
|
||||||
|
- current window TTL
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
||||||
|
"""
|
||||||
|
Resets the rate limit key(s) for the sliding window
|
||||||
|
|
||||||
|
:param key: the key to clear rate limits for
|
||||||
|
:param expiry: the rate limit expiry, needed to compute the key in some implemenations
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from math import floor
|
||||||
|
|
||||||
|
from deprecated.sphinx import versionadded, versionchanged
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
|
from limits.aio.storage import SlidingWindowCounterSupport, Storage
|
||||||
|
from limits.aio.storage.memcached.bridge import MemcachedBridge
|
||||||
|
from limits.aio.storage.memcached.emcache import EmcacheBridge
|
||||||
|
from limits.aio.storage.memcached.memcachio import MemcachioBridge
|
||||||
|
from limits.storage.base import TimestampedSlidingWindow
|
||||||
|
from limits.typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="2.1")
|
||||||
|
@versionchanged(
|
||||||
|
version="5.0",
|
||||||
|
reason="Switched default implementation to :pypi:`memcachio`",
|
||||||
|
)
|
||||||
|
class MemcachedStorage(Storage, SlidingWindowCounterSupport, TimestampedSlidingWindow):
|
||||||
|
"""
|
||||||
|
Rate limit storage with memcached as backend.
|
||||||
|
|
||||||
|
Depends on :pypi:`memcachio`
|
||||||
|
"""
|
||||||
|
|
||||||
|
STORAGE_SCHEME = ["async+memcached"]
|
||||||
|
"""The storage scheme for memcached to be used in an async context"""
|
||||||
|
|
||||||
|
DEPENDENCIES = {
|
||||||
|
"memcachio": Version("0.3"),
|
||||||
|
"emcache": Version("0.0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge: MemcachedBridge
|
||||||
|
storage_exceptions: tuple[Exception, ...]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
wrap_exceptions: bool = False,
|
||||||
|
implementation: Literal["memcachio", "emcache"] = "memcachio",
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param uri: memcached location of the form
|
||||||
|
``async+memcached://host:port,host:port``
|
||||||
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
||||||
|
:exc:`limits.errors.StorageError` before raising it.
|
||||||
|
:param implementation: Whether to use the client implementation from
|
||||||
|
|
||||||
|
- ``memcachio``: :class:`memcachio.Client`
|
||||||
|
- ``emcache``: :class:`emcache.Client`
|
||||||
|
:param options: all remaining keyword arguments are passed
|
||||||
|
directly to the constructor of :class:`memcachio.Client`
|
||||||
|
:raise ConfigurationError: when :pypi:`memcachio` is not available
|
||||||
|
"""
|
||||||
|
if implementation == "emcache":
|
||||||
|
self.bridge = EmcacheBridge(
|
||||||
|
uri, self.dependencies["emcache"].module, **options
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.bridge = MemcachioBridge(
|
||||||
|
uri, self.dependencies["memcachio"].module, **options
|
||||||
|
)
|
||||||
|
super().__init__(uri, wrap_exceptions=wrap_exceptions, **options)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]: # pragma: no cover
|
||||||
|
return self.bridge.base_exceptions
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the counter value for
|
||||||
|
"""
|
||||||
|
return await self.bridge.get(key)
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
"""
|
||||||
|
:param key: the key to clear rate limits for
|
||||||
|
"""
|
||||||
|
await self.bridge.clear(key)
|
||||||
|
|
||||||
|
async def incr(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
expiry: float,
|
||||||
|
amount: int = 1,
|
||||||
|
set_expiration_key: bool = True,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
increments the counter for a given rate limit key
|
||||||
|
|
||||||
|
:param key: the key to increment
|
||||||
|
:param expiry: amount in seconds for the key to expire in
|
||||||
|
window every hit.
|
||||||
|
:param amount: the number to increment by
|
||||||
|
:param set_expiration_key: if set to False, the expiration time won't be stored but the key will still expire
|
||||||
|
"""
|
||||||
|
return await self.bridge.incr(
|
||||||
|
key, expiry, amount, set_expiration_key=set_expiration_key
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the expiry for
|
||||||
|
"""
|
||||||
|
return await self.bridge.get_expiry(key)
|
||||||
|
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
return await self.bridge.check()
|
||||||
|
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
if amount > limit:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
||||||
|
(
|
||||||
|
previous_count,
|
||||||
|
previous_ttl,
|
||||||
|
current_count,
|
||||||
|
_,
|
||||||
|
) = await self._get_sliding_window_info(previous_key, current_key, expiry, now)
|
||||||
|
t0 = time.time()
|
||||||
|
weighted_count = previous_count * previous_ttl / expiry + current_count
|
||||||
|
if floor(weighted_count) + amount > limit:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Hit, increase the current counter.
|
||||||
|
# If the counter doesn't exist yet, set twice the theorical expiry.
|
||||||
|
# We don't need the expiration key as it is estimated with the timestamps directly.
|
||||||
|
current_count = await self.incr(
|
||||||
|
current_key, 2 * expiry, amount=amount, set_expiration_key=False
|
||||||
|
)
|
||||||
|
t1 = time.time()
|
||||||
|
actualised_previous_ttl = max(0, previous_ttl - (t1 - t0))
|
||||||
|
weighted_count = (
|
||||||
|
previous_count * actualised_previous_ttl / expiry + current_count
|
||||||
|
)
|
||||||
|
if floor(weighted_count) > limit:
|
||||||
|
# Another hit won the race condition: revert the increment and refuse this hit
|
||||||
|
# Limitation: during high concurrency at the end of the window,
|
||||||
|
# the counter is shifted and cannot be decremented, so less requests than expected are allowed.
|
||||||
|
await self.bridge.decr(current_key, amount, noreply=True)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
now = time.time()
|
||||||
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
||||||
|
return await self._get_sliding_window_info(
|
||||||
|
previous_key, current_key, expiry, now
|
||||||
|
)
|
||||||
|
|
||||||
|
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
||||||
|
now = time.time()
|
||||||
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
||||||
|
await asyncio.gather(self.clear(previous_key), self.clear(current_key))
|
||||||
|
|
||||||
|
async def _get_sliding_window_info(
|
||||||
|
self, previous_key: str, current_key: str, expiry: int, now: float
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
result = await self.bridge.get_many([previous_key, current_key])
|
||||||
|
|
||||||
|
previous_count = result.get(previous_key.encode("utf-8"), 0)
|
||||||
|
current_count = result.get(current_key.encode("utf-8"), 0)
|
||||||
|
|
||||||
|
if previous_count == 0:
|
||||||
|
previous_ttl = float(0)
|
||||||
|
else:
|
||||||
|
previous_ttl = (1 - (((now - expiry) / expiry) % 1)) * expiry
|
||||||
|
current_ttl = (1 - ((now / expiry) % 1)) * expiry + expiry
|
||||||
|
|
||||||
|
return previous_count, previous_ttl, current_count, current_ttl
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import urllib
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from limits.typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
class MemcachedBridge(ABC):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
dependency: ModuleType,
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
self.uri = uri
|
||||||
|
self.parsed_uri = urllib.parse.urlparse(self.uri)
|
||||||
|
self.dependency = dependency
|
||||||
|
self.hosts = []
|
||||||
|
self.options = options
|
||||||
|
|
||||||
|
sep = self.parsed_uri.netloc.strip().find("@") + 1
|
||||||
|
for loc in self.parsed_uri.netloc.strip()[sep:].split(","):
|
||||||
|
host, port = loc.split(":")
|
||||||
|
self.hosts.append((host, int(port)))
|
||||||
|
|
||||||
|
if self.parsed_uri.username:
|
||||||
|
self.options["username"] = self.parsed_uri.username
|
||||||
|
if self.parsed_uri.password:
|
||||||
|
self.options["password"] = self.parsed_uri.password
|
||||||
|
|
||||||
|
def _expiration_key(self, key: str) -> str:
|
||||||
|
"""
|
||||||
|
Return the expiration key for the given counter key.
|
||||||
|
|
||||||
|
Memcached doesn't natively return the expiration time or TTL for a given key,
|
||||||
|
so we implement the expiration time on a separate key.
|
||||||
|
"""
|
||||||
|
return key + "/expires"
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get(self, key: str) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_many(self, keys: Iterable[str]) -> dict[bytes, int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def clear(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def decr(self, key: str, amount: int = 1, noreply: bool = False) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def incr(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
expiry: float,
|
||||||
|
amount: int = 1,
|
||||||
|
set_expiration_key: bool = True,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_expiry(self, key: str) -> float: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def check(self) -> bool: ...
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from math import ceil
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from limits.typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
|
from .bridge import MemcachedBridge
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import emcache
|
||||||
|
|
||||||
|
|
||||||
|
class EmcacheBridge(MemcachedBridge):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
dependency: ModuleType,
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(uri, dependency, **options)
|
||||||
|
self._storage = None
|
||||||
|
|
||||||
|
async def get_storage(self) -> emcache.Client:
|
||||||
|
if not self._storage:
|
||||||
|
self._storage = await self.dependency.create_client(
|
||||||
|
[self.dependency.MemcachedHostAddress(h, p) for h, p in self.hosts],
|
||||||
|
**self.options,
|
||||||
|
)
|
||||||
|
assert self._storage
|
||||||
|
return self._storage
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
item = await (await self.get_storage()).get(key.encode("utf-8"))
|
||||||
|
return item and int(item.value) or 0
|
||||||
|
|
||||||
|
async def get_many(self, keys: Iterable[str]) -> dict[bytes, int]:
|
||||||
|
results = await (await self.get_storage()).get_many(
|
||||||
|
[k.encode("utf-8") for k in keys]
|
||||||
|
)
|
||||||
|
return {k: int(item.value) if item else 0 for k, item in results.items()}
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
try:
|
||||||
|
await (await self.get_storage()).delete(key.encode("utf-8"))
|
||||||
|
except self.dependency.NotFoundCommandError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def decr(self, key: str, amount: int = 1, noreply: bool = False) -> int:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
limit_key = key.encode("utf-8")
|
||||||
|
try:
|
||||||
|
value = await storage.decrement(limit_key, amount, noreply=noreply) or 0
|
||||||
|
except self.dependency.NotFoundCommandError:
|
||||||
|
value = 0
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def incr(
|
||||||
|
self, key: str, expiry: float, amount: int = 1, set_expiration_key: bool = True
|
||||||
|
) -> int:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
limit_key = key.encode("utf-8")
|
||||||
|
expire_key = self._expiration_key(key).encode()
|
||||||
|
try:
|
||||||
|
return await storage.increment(limit_key, amount) or amount
|
||||||
|
except self.dependency.NotFoundCommandError:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
try:
|
||||||
|
await storage.add(limit_key, f"{amount}".encode(), exptime=ceil(expiry))
|
||||||
|
if set_expiration_key:
|
||||||
|
await storage.set(
|
||||||
|
expire_key,
|
||||||
|
str(expiry + time.time()).encode("utf-8"),
|
||||||
|
exptime=ceil(expiry),
|
||||||
|
noreply=False,
|
||||||
|
)
|
||||||
|
value = amount
|
||||||
|
except self.dependency.NotStoredStorageCommandError:
|
||||||
|
# Coult not add the key, probably because a concurrent call has added it
|
||||||
|
storage = await self.get_storage()
|
||||||
|
value = await storage.increment(limit_key, amount) or amount
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
item = await storage.get(self._expiration_key(key).encode("utf-8"))
|
||||||
|
|
||||||
|
return item and float(item.value) or time.time()
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]: # pragma: no cover
|
||||||
|
return (
|
||||||
|
self.dependency.ClusterNoAvailableNodes,
|
||||||
|
self.dependency.CommandError,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if storage is healthy by calling the ``get`` command
|
||||||
|
on the key ``limiter-check``
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
await storage.get(b"limiter-check")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except: # noqa
|
||||||
|
return False
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from math import ceil
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
|
from .bridge import MemcachedBridge
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import memcachio
|
||||||
|
|
||||||
|
|
||||||
|
class MemcachioBridge(MemcachedBridge):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
dependency: ModuleType,
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(uri, dependency, **options)
|
||||||
|
self._storage: memcachio.Client[bytes] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]:
|
||||||
|
return (
|
||||||
|
self.dependency.errors.NoAvailableNodes,
|
||||||
|
self.dependency.errors.MemcachioConnectionError,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_storage(self) -> memcachio.Client[bytes]:
|
||||||
|
if not self._storage:
|
||||||
|
self._storage = self.dependency.Client(
|
||||||
|
[(h, p) for h, p in self.hosts],
|
||||||
|
**self.options,
|
||||||
|
)
|
||||||
|
assert self._storage
|
||||||
|
return self._storage
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
return (await self.get_many([key])).get(key.encode("utf-8"), 0)
|
||||||
|
|
||||||
|
async def get_many(self, keys: Iterable[str]) -> dict[bytes, int]:
|
||||||
|
"""
|
||||||
|
Return multiple counters at once
|
||||||
|
|
||||||
|
:param keys: the keys to get the counter values for
|
||||||
|
"""
|
||||||
|
results = await (await self.get_storage()).get(
|
||||||
|
*[k.encode("utf-8") for k in keys]
|
||||||
|
)
|
||||||
|
return {k: int(v.value) for k, v in results.items()}
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
await (await self.get_storage()).delete(key.encode("utf-8"))
|
||||||
|
|
||||||
|
async def decr(self, key: str, amount: int = 1, noreply: bool = False) -> int:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
limit_key = key.encode("utf-8")
|
||||||
|
return await storage.decr(limit_key, amount, noreply=noreply) or 0
|
||||||
|
|
||||||
|
async def incr(
|
||||||
|
self, key: str, expiry: float, amount: int = 1, set_expiration_key: bool = True
|
||||||
|
) -> int:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
limit_key = key.encode("utf-8")
|
||||||
|
expire_key = self._expiration_key(key).encode()
|
||||||
|
if (value := (await storage.incr(limit_key, amount))) is None:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
if await storage.add(limit_key, f"{amount}".encode(), expiry=ceil(expiry)):
|
||||||
|
if set_expiration_key:
|
||||||
|
await storage.set(
|
||||||
|
expire_key,
|
||||||
|
str(expiry + time.time()).encode("utf-8"),
|
||||||
|
expiry=ceil(expiry),
|
||||||
|
noreply=False,
|
||||||
|
)
|
||||||
|
return amount
|
||||||
|
else:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
return await storage.incr(limit_key, amount) or amount
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
expiration_key = self._expiration_key(key).encode("utf-8")
|
||||||
|
item = (await storage.get(expiration_key)).get(expiration_key, None)
|
||||||
|
|
||||||
|
return item and float(item.value) or time.time()
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if storage is healthy by calling the ``get`` command
|
||||||
|
on the key ``limiter-check``
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
storage = await self.get_storage()
|
||||||
|
await storage.get(b"limiter-check")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except: # noqa
|
||||||
|
return False
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import bisect
|
||||||
|
import time
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from math import floor
|
||||||
|
|
||||||
|
from deprecated.sphinx import versionadded
|
||||||
|
|
||||||
|
import limits.typing
|
||||||
|
from limits.aio.storage.base import (
|
||||||
|
MovingWindowSupport,
|
||||||
|
SlidingWindowCounterSupport,
|
||||||
|
Storage,
|
||||||
|
)
|
||||||
|
from limits.storage.base import TimestampedSlidingWindow
|
||||||
|
|
||||||
|
|
||||||
|
class Entry:
|
||||||
|
def __init__(self, expiry: int) -> None:
|
||||||
|
self.atime = time.time()
|
||||||
|
self.expiry = self.atime + expiry
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="2.1")
|
||||||
|
class MemoryStorage(
|
||||||
|
Storage, MovingWindowSupport, SlidingWindowCounterSupport, TimestampedSlidingWindow
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
rate limit storage using :class:`collections.Counter`
|
||||||
|
as an in memory storage for fixed & sliding window strategies,
|
||||||
|
and a simple list to implement moving window strategy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STORAGE_SCHEME = ["async+memory"]
|
||||||
|
"""
|
||||||
|
The storage scheme for in process memory storage for use in an
|
||||||
|
async context
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, uri: str | None = None, wrap_exceptions: bool = False, **_: str
|
||||||
|
) -> None:
|
||||||
|
self.storage: limits.typing.Counter[str] = Counter()
|
||||||
|
self.locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||||
|
self.expirations: dict[str, float] = {}
|
||||||
|
self.events: dict[str, list[Entry]] = {}
|
||||||
|
self.timer: asyncio.Task[None] | None = None
|
||||||
|
super().__init__(uri, wrap_exceptions=wrap_exceptions, **_)
|
||||||
|
|
||||||
|
def __getstate__(self) -> dict[str, limits.typing.Any]: # type: ignore[explicit-any]
|
||||||
|
state = self.__dict__.copy()
|
||||||
|
del state["timer"]
|
||||||
|
del state["locks"]
|
||||||
|
return state
|
||||||
|
|
||||||
|
def __setstate__(self, state: dict[str, limits.typing.Any]) -> None: # type: ignore[explicit-any]
|
||||||
|
self.__dict__.update(state)
|
||||||
|
self.timer = None
|
||||||
|
self.locks = defaultdict(asyncio.Lock)
|
||||||
|
asyncio.ensure_future(self.__schedule_expiry())
|
||||||
|
|
||||||
|
async def __expire_events(self) -> None:
|
||||||
|
try:
|
||||||
|
now = time.time()
|
||||||
|
for key in list(self.events.keys()):
|
||||||
|
async with self.locks[key]:
|
||||||
|
cutoff = await asyncio.to_thread(
|
||||||
|
lambda evts: bisect.bisect_left(
|
||||||
|
evts, -now, key=lambda event: -event.expiry
|
||||||
|
),
|
||||||
|
self.events[key],
|
||||||
|
)
|
||||||
|
if self.events.get(key, []):
|
||||||
|
self.events[key] = self.events[key][:cutoff]
|
||||||
|
if not self.events.get(key, None):
|
||||||
|
self.events.pop(key, None)
|
||||||
|
self.locks.pop(key, None)
|
||||||
|
|
||||||
|
for key in list(self.expirations.keys()):
|
||||||
|
if self.expirations[key] <= time.time():
|
||||||
|
self.storage.pop(key, None)
|
||||||
|
self.expirations.pop(key, None)
|
||||||
|
self.locks.pop(key, None)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def __schedule_expiry(self) -> None:
|
||||||
|
if not self.timer or self.timer.done():
|
||||||
|
self.timer = asyncio.create_task(self.__expire_events())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]: # pragma: no cover
|
||||||
|
return ValueError
|
||||||
|
|
||||||
|
async def incr(self, key: str, expiry: float, amount: int = 1) -> int:
|
||||||
|
"""
|
||||||
|
increments the counter for a given rate limit key
|
||||||
|
|
||||||
|
:param key: the key to increment
|
||||||
|
:param expiry: amount in seconds for the key to expire in
|
||||||
|
:param amount: the number to increment by
|
||||||
|
"""
|
||||||
|
await self.get(key)
|
||||||
|
await self.__schedule_expiry()
|
||||||
|
async with self.locks[key]:
|
||||||
|
self.storage[key] += amount
|
||||||
|
if self.storage[key] == amount:
|
||||||
|
self.expirations[key] = time.time() + expiry
|
||||||
|
return self.storage.get(key, amount)
|
||||||
|
|
||||||
|
async def decr(self, key: str, amount: int = 1) -> int:
|
||||||
|
"""
|
||||||
|
decrements the counter for a given rate limit key. 0 is the minimum allowed value.
|
||||||
|
|
||||||
|
:param amount: the number to increment by
|
||||||
|
"""
|
||||||
|
await self.get(key)
|
||||||
|
await self.__schedule_expiry()
|
||||||
|
async with self.locks[key]:
|
||||||
|
self.storage[key] = max(self.storage[key] - amount, 0)
|
||||||
|
|
||||||
|
return self.storage.get(key, amount)
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the counter value for
|
||||||
|
"""
|
||||||
|
if self.expirations.get(key, 0) <= time.time():
|
||||||
|
self.storage.pop(key, None)
|
||||||
|
self.expirations.pop(key, None)
|
||||||
|
self.locks.pop(key, None)
|
||||||
|
|
||||||
|
return self.storage.get(key, 0)
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
"""
|
||||||
|
:param key: the key to clear rate limits for
|
||||||
|
"""
|
||||||
|
self.storage.pop(key, None)
|
||||||
|
self.expirations.pop(key, None)
|
||||||
|
self.events.pop(key, None)
|
||||||
|
self.locks.pop(key, None)
|
||||||
|
|
||||||
|
async def acquire_entry(
|
||||||
|
self, key: str, limit: int, expiry: int, amount: int = 1
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
:param key: rate limit key to acquire an entry in
|
||||||
|
:param limit: amount of entries allowed
|
||||||
|
:param expiry: expiry of the entry
|
||||||
|
:param amount: the number of entries to acquire
|
||||||
|
"""
|
||||||
|
if amount > limit:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self.__schedule_expiry()
|
||||||
|
async with self.locks[key]:
|
||||||
|
self.events.setdefault(key, [])
|
||||||
|
timestamp = time.time()
|
||||||
|
try:
|
||||||
|
entry: Entry | None = self.events[key][limit - amount]
|
||||||
|
except IndexError:
|
||||||
|
entry = None
|
||||||
|
|
||||||
|
if entry and entry.atime >= timestamp - expiry:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.events[key][:0] = [Entry(expiry)] * amount
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the expiry for
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.expirations.get(key, time.time())
|
||||||
|
|
||||||
|
async def get_moving_window(
|
||||||
|
self, key: str, limit: int, expiry: int
|
||||||
|
) -> tuple[float, int]:
|
||||||
|
"""
|
||||||
|
returns the starting point and the number of entries in the moving
|
||||||
|
window
|
||||||
|
|
||||||
|
:param key: rate limit key
|
||||||
|
:param expiry: expiry of entry
|
||||||
|
:return: (start of window, number of acquired entries)
|
||||||
|
"""
|
||||||
|
|
||||||
|
timestamp = time.time()
|
||||||
|
if events := self.events.get(key, []):
|
||||||
|
oldest = bisect.bisect_left(
|
||||||
|
events, -(timestamp - expiry), key=lambda entry: -entry.atime
|
||||||
|
)
|
||||||
|
return events[oldest - 1].atime, oldest
|
||||||
|
return timestamp, 0
|
||||||
|
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
if amount > limit:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
||||||
|
(
|
||||||
|
previous_count,
|
||||||
|
previous_ttl,
|
||||||
|
current_count,
|
||||||
|
_,
|
||||||
|
) = await self._get_sliding_window_info(previous_key, current_key, expiry, now)
|
||||||
|
weighted_count = previous_count * previous_ttl / expiry + current_count
|
||||||
|
if floor(weighted_count) + amount > limit:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Hit, increase the current counter.
|
||||||
|
# If the counter doesn't exist yet, set twice the theorical expiry.
|
||||||
|
current_count = await self.incr(current_key, 2 * expiry, amount=amount)
|
||||||
|
weighted_count = previous_count * previous_ttl / expiry + current_count
|
||||||
|
if floor(weighted_count) > limit:
|
||||||
|
# Another hit won the race condition: revert the incrementation and refuse this hit
|
||||||
|
# Limitation: during high concurrency at the end of the window,
|
||||||
|
# the counter is shifted and cannot be decremented, so less requests than expected are allowed.
|
||||||
|
await self.decr(current_key, amount)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
now = time.time()
|
||||||
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
||||||
|
return await self._get_sliding_window_info(
|
||||||
|
previous_key, current_key, expiry, now
|
||||||
|
)
|
||||||
|
|
||||||
|
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
||||||
|
now = time.time()
|
||||||
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
||||||
|
await self.clear(current_key)
|
||||||
|
await self.clear(previous_key)
|
||||||
|
|
||||||
|
async def _get_sliding_window_info(
|
||||||
|
self,
|
||||||
|
previous_key: str,
|
||||||
|
current_key: str,
|
||||||
|
expiry: int,
|
||||||
|
now: float,
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
previous_count = await self.get(previous_key)
|
||||||
|
current_count = await self.get(current_key)
|
||||||
|
if previous_count == 0:
|
||||||
|
previous_ttl = float(0)
|
||||||
|
else:
|
||||||
|
previous_ttl = (1 - (((now - expiry) / expiry) % 1)) * expiry
|
||||||
|
current_ttl = (1 - ((now / expiry) % 1)) * expiry + expiry
|
||||||
|
return previous_count, previous_ttl, current_count, current_ttl
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
check if storage is healthy
|
||||||
|
"""
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
num_items = max(len(self.storage), len(self.events))
|
||||||
|
self.storage.clear()
|
||||||
|
self.expirations.clear()
|
||||||
|
self.events.clear()
|
||||||
|
self.locks.clear()
|
||||||
|
|
||||||
|
return num_items
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.timer and not self.timer.done():
|
||||||
|
self.timer.cancel()
|
||||||
|
except RuntimeError: # noqa
|
||||||
|
pass
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
|
from deprecated.sphinx import versionadded, versionchanged
|
||||||
|
|
||||||
|
from limits.aio.storage.base import (
|
||||||
|
MovingWindowSupport,
|
||||||
|
SlidingWindowCounterSupport,
|
||||||
|
Storage,
|
||||||
|
)
|
||||||
|
from limits.typing import (
|
||||||
|
ParamSpec,
|
||||||
|
TypeVar,
|
||||||
|
cast,
|
||||||
|
)
|
||||||
|
from limits.util import get_dependency
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="2.1")
|
||||||
|
@versionchanged(
|
||||||
|
version="3.14.0",
|
||||||
|
reason="Added option to select custom collection names for windows & counters",
|
||||||
|
)
|
||||||
|
class MongoDBStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
||||||
|
"""
|
||||||
|
Rate limit storage with MongoDB as backend.
|
||||||
|
|
||||||
|
Depends on :pypi:`motor`
|
||||||
|
"""
|
||||||
|
|
||||||
|
STORAGE_SCHEME = ["async+mongodb", "async+mongodb+srv"]
|
||||||
|
"""
|
||||||
|
The storage scheme for MongoDB for use in an async context
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEPENDENCIES = ["motor.motor_asyncio", "pymongo"]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
database_name: str = "limits",
|
||||||
|
counter_collection_name: str = "counters",
|
||||||
|
window_collection_name: str = "windows",
|
||||||
|
wrap_exceptions: bool = False,
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param uri: uri of the form ``async+mongodb://[user:password]@host:port?...``,
|
||||||
|
This uri is passed directly to :class:`~motor.motor_asyncio.AsyncIOMotorClient`
|
||||||
|
:param database_name: The database to use for storing the rate limit
|
||||||
|
collections.
|
||||||
|
:param counter_collection_name: The collection name to use for individual counters
|
||||||
|
used in fixed window strategies
|
||||||
|
:param window_collection_name: The collection name to use for sliding & moving window
|
||||||
|
storage
|
||||||
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
||||||
|
:exc:`limits.errors.StorageError` before raising it.
|
||||||
|
:param options: all remaining keyword arguments are passed
|
||||||
|
to the constructor of :class:`~motor.motor_asyncio.AsyncIOMotorClient`
|
||||||
|
:raise ConfigurationError: when the :pypi:`motor` or :pypi:`pymongo` are
|
||||||
|
not available
|
||||||
|
"""
|
||||||
|
|
||||||
|
uri = uri.replace("async+mongodb", "mongodb", 1)
|
||||||
|
|
||||||
|
super().__init__(uri, wrap_exceptions=wrap_exceptions, **options)
|
||||||
|
|
||||||
|
self.dependency = self.dependencies["motor.motor_asyncio"]
|
||||||
|
self.proxy_dependency = self.dependencies["pymongo"]
|
||||||
|
self.lib_errors, _ = get_dependency("pymongo.errors")
|
||||||
|
|
||||||
|
self.storage = self.dependency.module.AsyncIOMotorClient(uri, **options)
|
||||||
|
# TODO: Fix this hack. It was noticed when running a benchmark
|
||||||
|
# with FastAPI - however - doesn't appear in unit tests or in an isolated
|
||||||
|
# use. Reference: https://jira.mongodb.org/browse/MOTOR-822
|
||||||
|
self.storage.get_io_loop = asyncio.get_running_loop
|
||||||
|
|
||||||
|
self.__database_name = database_name
|
||||||
|
self.__collection_mapping = {
|
||||||
|
"counters": counter_collection_name,
|
||||||
|
"windows": window_collection_name,
|
||||||
|
}
|
||||||
|
self.__indices_created = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]: # pragma: no cover
|
||||||
|
return self.lib_errors.PyMongoError # type: ignore
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database(self): # type: ignore
|
||||||
|
return self.storage.get_database(self.__database_name)
|
||||||
|
|
||||||
|
async def create_indices(self) -> None:
|
||||||
|
if not self.__indices_created:
|
||||||
|
await asyncio.gather(
|
||||||
|
self.database[self.__collection_mapping["counters"]].create_index(
|
||||||
|
"expireAt", expireAfterSeconds=0
|
||||||
|
),
|
||||||
|
self.database[self.__collection_mapping["windows"]].create_index(
|
||||||
|
"expireAt", expireAfterSeconds=0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.__indices_created = True
|
||||||
|
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
"""
|
||||||
|
Delete all rate limit keys in the rate limit collections (counters, windows)
|
||||||
|
"""
|
||||||
|
num_keys = sum(
|
||||||
|
await asyncio.gather(
|
||||||
|
self.database[self.__collection_mapping["counters"]].count_documents(
|
||||||
|
{}
|
||||||
|
),
|
||||||
|
self.database[self.__collection_mapping["windows"]].count_documents({}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.gather(
|
||||||
|
self.database[self.__collection_mapping["counters"]].drop(),
|
||||||
|
self.database[self.__collection_mapping["windows"]].drop(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cast(int, num_keys)
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
"""
|
||||||
|
:param key: the key to clear rate limits for
|
||||||
|
"""
|
||||||
|
await asyncio.gather(
|
||||||
|
self.database[self.__collection_mapping["counters"]].find_one_and_delete(
|
||||||
|
{"_id": key}
|
||||||
|
),
|
||||||
|
self.database[self.__collection_mapping["windows"]].find_one_and_delete(
|
||||||
|
{"_id": key}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the expiry for
|
||||||
|
"""
|
||||||
|
counter = await self.database[self.__collection_mapping["counters"]].find_one(
|
||||||
|
{"_id": key}
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
(counter["expireAt"] if counter else datetime.datetime.now())
|
||||||
|
.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the counter value for
|
||||||
|
"""
|
||||||
|
counter = await self.database[self.__collection_mapping["counters"]].find_one(
|
||||||
|
{
|
||||||
|
"_id": key,
|
||||||
|
"expireAt": {"$gte": datetime.datetime.now(datetime.timezone.utc)},
|
||||||
|
},
|
||||||
|
projection=["count"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return counter and counter["count"] or 0
|
||||||
|
|
||||||
|
async def incr(self, key: str, expiry: int, amount: int = 1) -> int:
|
||||||
|
"""
|
||||||
|
increments the counter for a given rate limit key
|
||||||
|
|
||||||
|
:param key: the key to increment
|
||||||
|
:param expiry: amount in seconds for the key to expire in
|
||||||
|
:param amount: the number to increment by
|
||||||
|
"""
|
||||||
|
await self.create_indices()
|
||||||
|
|
||||||
|
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
|
||||||
|
seconds=expiry
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self.database[
|
||||||
|
self.__collection_mapping["counters"]
|
||||||
|
].find_one_and_update(
|
||||||
|
{"_id": key},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"count": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {"$lt": ["$expireAt", "$$NOW"]},
|
||||||
|
"then": amount,
|
||||||
|
"else": {"$add": ["$count", amount]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expireAt": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {"$lt": ["$expireAt", "$$NOW"]},
|
||||||
|
"then": expiration,
|
||||||
|
"else": "$expireAt",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
upsert=True,
|
||||||
|
projection=["count"],
|
||||||
|
return_document=self.proxy_dependency.module.ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
|
||||||
|
return int(response["count"])
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if storage is healthy by calling
|
||||||
|
:meth:`motor.motor_asyncio.AsyncIOMotorClient.server_info`
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.storage.server_info()
|
||||||
|
|
||||||
|
return True
|
||||||
|
except: # noqa: E722
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_moving_window(
|
||||||
|
self, key: str, limit: int, expiry: int
|
||||||
|
) -> tuple[float, int]:
|
||||||
|
"""
|
||||||
|
returns the starting point and the number of entries in the moving
|
||||||
|
window
|
||||||
|
|
||||||
|
:param str key: rate limit key
|
||||||
|
:param int expiry: expiry of entry
|
||||||
|
:return: (start of window, number of acquired entries)
|
||||||
|
"""
|
||||||
|
|
||||||
|
timestamp = time.time()
|
||||||
|
if (
|
||||||
|
result := await self.database[self.__collection_mapping["windows"]]
|
||||||
|
.aggregate(
|
||||||
|
[
|
||||||
|
{"$match": {"_id": key}},
|
||||||
|
{
|
||||||
|
"$project": {
|
||||||
|
"filteredEntries": {
|
||||||
|
"$filter": {
|
||||||
|
"input": "$entries",
|
||||||
|
"as": "entry",
|
||||||
|
"cond": {"$gte": ["$$entry", timestamp - expiry]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$project": {
|
||||||
|
"min": {"$min": "$filteredEntries"},
|
||||||
|
"count": {"$size": "$filteredEntries"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.to_list(length=1)
|
||||||
|
):
|
||||||
|
return result[0]["min"], result[0]["count"]
|
||||||
|
return timestamp, 0
|
||||||
|
|
||||||
|
async def acquire_entry(
|
||||||
|
self, key: str, limit: int, expiry: int, amount: int = 1
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
:param key: rate limit key to acquire an entry in
|
||||||
|
:param limit: amount of entries allowed
|
||||||
|
:param expiry: expiry of the entry
|
||||||
|
:param amount: the number of entries to acquire
|
||||||
|
"""
|
||||||
|
await self.create_indices()
|
||||||
|
|
||||||
|
if amount > limit:
|
||||||
|
return False
|
||||||
|
|
||||||
|
timestamp = time.time()
|
||||||
|
try:
|
||||||
|
updates: dict[
|
||||||
|
str,
|
||||||
|
dict[str, datetime.datetime | dict[str, list[float] | int]],
|
||||||
|
] = {
|
||||||
|
"$push": {
|
||||||
|
"entries": {
|
||||||
|
"$each": [timestamp] * amount,
|
||||||
|
"$position": 0,
|
||||||
|
"$slice": limit,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$set": {
|
||||||
|
"expireAt": (
|
||||||
|
datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
+ datetime.timedelta(seconds=expiry)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.database[self.__collection_mapping["windows"]].update_one(
|
||||||
|
{
|
||||||
|
"_id": key,
|
||||||
|
f"entries.{limit - amount}": {"$not": {"$gte": timestamp - expiry}},
|
||||||
|
},
|
||||||
|
updates,
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except self.proxy_dependency.module.errors.DuplicateKeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self, key: str, limit: int, expiry: int, amount: int = 1
|
||||||
|
) -> bool:
|
||||||
|
await self.create_indices()
|
||||||
|
expiry_ms = expiry * 1000
|
||||||
|
result = await self.database[
|
||||||
|
self.__collection_mapping["windows"]
|
||||||
|
].find_one_and_update(
|
||||||
|
{"_id": key},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"previousCount": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {
|
||||||
|
"$lte": [
|
||||||
|
{"$subtract": ["$expireAt", "$$NOW"]},
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {"$ifNull": ["$currentCount", 0]},
|
||||||
|
"else": {"$ifNull": ["$previousCount", 0]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"currentCount": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {
|
||||||
|
"$lte": [
|
||||||
|
{"$subtract": ["$expireAt", "$$NOW"]},
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": 0,
|
||||||
|
"else": {"$ifNull": ["$currentCount", 0]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expireAt": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {
|
||||||
|
"$lte": [
|
||||||
|
{"$subtract": ["$expireAt", "$$NOW"]},
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {"$gt": ["$expireAt", 0]},
|
||||||
|
"then": {"$add": ["$expireAt", expiry_ms]},
|
||||||
|
"else": {"$add": ["$$NOW", 2 * expiry_ms]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"else": "$expireAt",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"curWeightedCount": {
|
||||||
|
"$floor": {
|
||||||
|
"$add": [
|
||||||
|
{
|
||||||
|
"$multiply": [
|
||||||
|
"$previousCount",
|
||||||
|
{
|
||||||
|
"$divide": [
|
||||||
|
{
|
||||||
|
"$max": [
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"$subtract": [
|
||||||
|
"$expireAt",
|
||||||
|
{
|
||||||
|
"$add": [
|
||||||
|
"$$NOW",
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"$currentCount",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"currentCount": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {
|
||||||
|
"$lte": [
|
||||||
|
{"$add": ["$curWeightedCount", amount]},
|
||||||
|
limit,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {"$add": ["$currentCount", amount]},
|
||||||
|
"else": "$currentCount",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"_acquired": {
|
||||||
|
"$lte": [{"$add": ["$curWeightedCount", amount]}, limit]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"$unset": ["curWeightedCount"]},
|
||||||
|
],
|
||||||
|
return_document=self.proxy_dependency.module.ReturnDocument.AFTER,
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cast(bool, result["_acquired"])
|
||||||
|
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
expiry_ms = expiry * 1000
|
||||||
|
if result := await self.database[
|
||||||
|
self.__collection_mapping["windows"]
|
||||||
|
].find_one_and_update(
|
||||||
|
{"_id": key},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"previousCount": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {
|
||||||
|
"$lte": [
|
||||||
|
{"$subtract": ["$expireAt", "$$NOW"]},
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {"$ifNull": ["$currentCount", 0]},
|
||||||
|
"else": {"$ifNull": ["$previousCount", 0]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"currentCount": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {
|
||||||
|
"$lte": [
|
||||||
|
{"$subtract": ["$expireAt", "$$NOW"]},
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": 0,
|
||||||
|
"else": {"$ifNull": ["$currentCount", 0]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expireAt": {
|
||||||
|
"$cond": {
|
||||||
|
"if": {
|
||||||
|
"$lte": [
|
||||||
|
{"$subtract": ["$expireAt", "$$NOW"]},
|
||||||
|
expiry_ms,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {"$add": ["$expireAt", expiry_ms]},
|
||||||
|
"else": "$expireAt",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
return_document=self.proxy_dependency.module.ReturnDocument.AFTER,
|
||||||
|
projection=["currentCount", "previousCount", "expireAt"],
|
||||||
|
):
|
||||||
|
expires_at = (
|
||||||
|
(result["expireAt"].replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||||
|
if result.get("expireAt")
|
||||||
|
else time.time()
|
||||||
|
)
|
||||||
|
current_ttl = max(0, expires_at - time.time())
|
||||||
|
prev_ttl = max(0, current_ttl - expiry if result["previousCount"] else 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
result["previousCount"],
|
||||||
|
prev_ttl,
|
||||||
|
result["currentCount"],
|
||||||
|
current_ttl,
|
||||||
|
)
|
||||||
|
return 0, 0.0, 0, 0.0
|
||||||
|
|
||||||
|
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
||||||
|
return await self.clear(key)
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
self.storage and self.storage.close()
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from deprecated.sphinx import versionadded, versionchanged
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
|
from limits.aio.storage import MovingWindowSupport, SlidingWindowCounterSupport, Storage
|
||||||
|
from limits.aio.storage.redis.bridge import RedisBridge
|
||||||
|
from limits.aio.storage.redis.coredis import CoredisBridge
|
||||||
|
from limits.aio.storage.redis.redispy import RedispyBridge
|
||||||
|
from limits.aio.storage.redis.valkey import ValkeyBridge
|
||||||
|
from limits.typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="2.1")
|
||||||
|
@versionchanged(
|
||||||
|
version="4.2",
|
||||||
|
reason=(
|
||||||
|
"Added support for using the asyncio redis client from :pypi:`redis`"
|
||||||
|
" through :paramref:`implementation`"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@versionchanged(
|
||||||
|
version="4.3",
|
||||||
|
reason=(
|
||||||
|
"Added support for using the asyncio redis client from :pypi:`valkey`"
|
||||||
|
" through :paramref:`implementation` or if :paramref:`uri` has the"
|
||||||
|
" ``async+valkey`` schema"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
||||||
|
"""
|
||||||
|
Rate limit storage with redis as backend.
|
||||||
|
|
||||||
|
Depends on :pypi:`coredis` or :pypi:`redis`
|
||||||
|
"""
|
||||||
|
|
||||||
|
STORAGE_SCHEME = [
|
||||||
|
"async+redis",
|
||||||
|
"async+rediss",
|
||||||
|
"async+redis+unix",
|
||||||
|
"async+valkey",
|
||||||
|
"async+valkeys",
|
||||||
|
"async+valkey+unix",
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
The storage schemes for redis to be used in an async context
|
||||||
|
"""
|
||||||
|
DEPENDENCIES = {
|
||||||
|
"redis": Version("5.2.0"),
|
||||||
|
"coredis": Version("3.4.0"),
|
||||||
|
"valkey": Version("6.0"),
|
||||||
|
}
|
||||||
|
MODE: Literal["BASIC", "CLUSTER", "SENTINEL"] = "BASIC"
|
||||||
|
PREFIX = "LIMITS"
|
||||||
|
|
||||||
|
bridge: RedisBridge
|
||||||
|
storage_exceptions: tuple[Exception, ...]
|
||||||
|
target_server: Literal["redis", "valkey"]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
wrap_exceptions: bool = False,
|
||||||
|
implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
|
||||||
|
key_prefix: str = PREFIX,
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param uri: uri of the form:
|
||||||
|
|
||||||
|
- ``async+redis://[:password]@host:port``
|
||||||
|
- ``async+redis://[:password]@host:port/db``
|
||||||
|
- ``async+rediss://[:password]@host:port``
|
||||||
|
- ``async+redis+unix:///path/to/sock?db=0`` etc...
|
||||||
|
|
||||||
|
This uri is passed directly to :meth:`coredis.Redis.from_url` or
|
||||||
|
:meth:`redis.asyncio.client.Redis.from_url` with the initial ``async`` removed,
|
||||||
|
except for the case of ``async+redis+unix`` where it is replaced with ``unix``.
|
||||||
|
|
||||||
|
If the uri scheme is ``async+valkey`` the implementation used will be from
|
||||||
|
:pypi:`valkey`.
|
||||||
|
:param connection_pool: if provided, the redis client is initialized with
|
||||||
|
the connection pool and any other params passed as :paramref:`options`
|
||||||
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
||||||
|
:exc:`limits.errors.StorageError` before raising it.
|
||||||
|
:param implementation: Whether to use the client implementation from
|
||||||
|
|
||||||
|
- ``coredis``: :class:`coredis.Redis`
|
||||||
|
- ``redispy``: :class:`redis.asyncio.client.Redis`
|
||||||
|
- ``valkey``: :class:`valkey.asyncio.client.Valkey`
|
||||||
|
|
||||||
|
:param key_prefix: the prefix for each key created in redis
|
||||||
|
:param options: all remaining keyword arguments are passed
|
||||||
|
directly to the constructor of :class:`coredis.Redis` or :class:`redis.asyncio.client.Redis`
|
||||||
|
:raise ConfigurationError: when the redis library is not available
|
||||||
|
"""
|
||||||
|
uri = uri.removeprefix("async+")
|
||||||
|
self.target_server = "redis" if uri.startswith("redis") else "valkey"
|
||||||
|
uri = uri.replace(f"{self.target_server}+unix", "unix")
|
||||||
|
|
||||||
|
super().__init__(uri, wrap_exceptions=wrap_exceptions)
|
||||||
|
self.options = options
|
||||||
|
if self.target_server == "valkey" or implementation == "valkey":
|
||||||
|
self.bridge = ValkeyBridge(
|
||||||
|
uri, self.dependencies["valkey"].module, key_prefix
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if implementation == "redispy":
|
||||||
|
self.bridge = RedispyBridge(
|
||||||
|
uri, self.dependencies["redis"].module, key_prefix
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.bridge = CoredisBridge(
|
||||||
|
uri, self.dependencies["coredis"].module, key_prefix
|
||||||
|
)
|
||||||
|
self.configure_bridge()
|
||||||
|
self.bridge.register_scripts()
|
||||||
|
|
||||||
|
def _current_window_key(self, key: str) -> str:
|
||||||
|
"""
|
||||||
|
Return the current window's storage key (Sliding window strategy)
|
||||||
|
|
||||||
|
Contrary to other strategies that have one key per rate limit item,
|
||||||
|
this strategy has two keys per rate limit item than must be on the same machine.
|
||||||
|
To keep the current key and the previous key on the same Redis cluster node,
|
||||||
|
curly braces are added.
|
||||||
|
|
||||||
|
Eg: "{constructed_key}"
|
||||||
|
"""
|
||||||
|
return f"{{{key}}}"
|
||||||
|
|
||||||
|
def _previous_window_key(self, key: str) -> str:
|
||||||
|
"""
|
||||||
|
Return the previous window's storage key (Sliding window strategy).
|
||||||
|
|
||||||
|
Curvy braces are added on the common pattern with the current window's key,
|
||||||
|
so the current and the previous key are stored on the same Redis cluster node.
|
||||||
|
|
||||||
|
Eg: "{constructed_key}/-1"
|
||||||
|
"""
|
||||||
|
return f"{self._current_window_key(key)}/-1"
|
||||||
|
|
||||||
|
def configure_bridge(self) -> None:
|
||||||
|
self.bridge.use_basic(**self.options)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]: # pragma: no cover
|
||||||
|
return self.bridge.base_exceptions
|
||||||
|
|
||||||
|
async def incr(self, key: str, expiry: int, amount: int = 1) -> int:
|
||||||
|
"""
|
||||||
|
increments the counter for a given rate limit key
|
||||||
|
|
||||||
|
:param key: the key to increment
|
||||||
|
:param expiry: amount in seconds for the key to expire in
|
||||||
|
:param amount: the number to increment by
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.incr(key, expiry, amount)
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the counter value for
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.get(key)
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
"""
|
||||||
|
:param key: the key to clear rate limits for
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.clear(key)
|
||||||
|
|
||||||
|
async def acquire_entry(
|
||||||
|
self, key: str, limit: int, expiry: int, amount: int = 1
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
:param key: rate limit key to acquire an entry in
|
||||||
|
:param limit: amount of entries allowed
|
||||||
|
:param expiry: expiry of the entry
|
||||||
|
:param amount: the number of entries to acquire
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.acquire_entry(key, limit, expiry, amount)
|
||||||
|
|
||||||
|
async def get_moving_window(
|
||||||
|
self, key: str, limit: int, expiry: int
|
||||||
|
) -> tuple[float, int]:
|
||||||
|
"""
|
||||||
|
returns the starting point and the number of entries in the moving
|
||||||
|
window
|
||||||
|
|
||||||
|
:param key: rate limit key
|
||||||
|
:param expiry: expiry of entry
|
||||||
|
:return: (previous count, previous TTL, current count, current TTL)
|
||||||
|
"""
|
||||||
|
return await self.bridge.get_moving_window(key, limit, expiry)
|
||||||
|
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
current_key = self._current_window_key(key)
|
||||||
|
previous_key = self._previous_window_key(key)
|
||||||
|
return await self.bridge.acquire_sliding_window_entry(
|
||||||
|
previous_key, current_key, limit, expiry, amount
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
previous_key = self._previous_window_key(key)
|
||||||
|
current_key = self._current_window_key(key)
|
||||||
|
return await self.bridge.get_sliding_window(previous_key, current_key, expiry)
|
||||||
|
|
||||||
|
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
||||||
|
previous_key = self._previous_window_key(key)
|
||||||
|
current_key = self._current_window_key(key)
|
||||||
|
await asyncio.gather(self.clear(previous_key), self.clear(current_key))
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the expiry for
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.get_expiry(key)
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if storage is healthy by calling ``PING``
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.check()
|
||||||
|
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
"""
|
||||||
|
This function calls a Lua Script to delete keys prefixed with
|
||||||
|
:paramref:`RedisStorage.key_prefix` in blocks of 5000.
|
||||||
|
|
||||||
|
.. warning:: This operation was designed to be fast, but was not tested
|
||||||
|
on a large production based system. Be careful with its usage as it
|
||||||
|
could be slow on very large data sets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.lua_reset()
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="2.1")
|
||||||
|
@versionchanged(
|
||||||
|
version="4.2",
|
||||||
|
reason="Added support for using the asyncio redis client from :pypi:`redis` ",
|
||||||
|
)
|
||||||
|
@versionchanged(
|
||||||
|
version="4.3",
|
||||||
|
reason=(
|
||||||
|
"Added support for using the asyncio redis client from :pypi:`valkey`"
|
||||||
|
" through :paramref:`implementation` or if :paramref:`uri` has the"
|
||||||
|
" ``async+valkey+cluster`` schema"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class RedisClusterStorage(RedisStorage):
|
||||||
|
"""
|
||||||
|
Rate limit storage with redis cluster as backend
|
||||||
|
|
||||||
|
Depends on :pypi:`coredis` or :pypi:`redis`
|
||||||
|
"""
|
||||||
|
|
||||||
|
STORAGE_SCHEME = ["async+redis+cluster", "async+valkey+cluster"]
|
||||||
|
"""
|
||||||
|
The storage schemes for redis cluster to be used in an async context
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODE = "CLUSTER"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
wrap_exceptions: bool = False,
|
||||||
|
implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
|
||||||
|
key_prefix: str = RedisStorage.PREFIX,
|
||||||
|
**options: float | str | bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param uri: url of the form
|
||||||
|
``async+redis+cluster://[:password]@host:port,host:port``
|
||||||
|
|
||||||
|
If the uri scheme is ``async+valkey+cluster`` the implementation used will be from
|
||||||
|
:pypi:`valkey`.
|
||||||
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
||||||
|
:exc:`limits.errors.StorageError` before raising it.
|
||||||
|
:param implementation: Whether to use the client implementation from
|
||||||
|
|
||||||
|
- ``coredis``: :class:`coredis.RedisCluster`
|
||||||
|
- ``redispy``: :class:`redis.asyncio.cluster.RedisCluster`
|
||||||
|
- ``valkey``: :class:`valkey.asyncio.cluster.ValkeyCluster`
|
||||||
|
:param key_prefix: the prefix for each key created in redis
|
||||||
|
:param options: all remaining keyword arguments are passed
|
||||||
|
directly to the constructor of :class:`coredis.RedisCluster` or
|
||||||
|
:class:`redis.asyncio.RedisCluster`
|
||||||
|
:raise ConfigurationError: when the redis library is not
|
||||||
|
available or if the redis host cannot be pinged.
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
uri,
|
||||||
|
wrap_exceptions=wrap_exceptions,
|
||||||
|
implementation=implementation,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
**options,
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure_bridge(self) -> None:
|
||||||
|
self.bridge.use_cluster(**self.options)
|
||||||
|
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
"""
|
||||||
|
Redis Clusters are sharded and deleting across shards
|
||||||
|
can't be done atomically. Because of this, this reset loops over all
|
||||||
|
keys that are prefixed with :paramref:`RedisClusterStorage.key_prefix`
|
||||||
|
and calls delete on them one at a time.
|
||||||
|
|
||||||
|
.. warning:: This operation was not tested with extremely large data sets.
|
||||||
|
On a large production based system, care should be taken with its
|
||||||
|
usage as it could be slow on very large data sets
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.bridge.reset()
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="2.1")
|
||||||
|
@versionchanged(
|
||||||
|
version="4.2",
|
||||||
|
reason="Added support for using the asyncio redis client from :pypi:`redis` ",
|
||||||
|
)
|
||||||
|
@versionchanged(
|
||||||
|
version="4.3",
|
||||||
|
reason=(
|
||||||
|
"Added support for using the asyncio redis client from :pypi:`valkey`"
|
||||||
|
" through :paramref:`implementation` or if :paramref:`uri` has the"
|
||||||
|
" ``async+valkey+sentinel`` schema"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class RedisSentinelStorage(RedisStorage):
|
||||||
|
"""
|
||||||
|
Rate limit storage with redis sentinel as backend
|
||||||
|
|
||||||
|
Depends on :pypi:`coredis` or :pypi:`redis`
|
||||||
|
"""
|
||||||
|
|
||||||
|
STORAGE_SCHEME = [
|
||||||
|
"async+redis+sentinel",
|
||||||
|
"async+valkey+sentinel",
|
||||||
|
]
|
||||||
|
"""The storage scheme for redis accessed via a redis sentinel installation"""
|
||||||
|
|
||||||
|
MODE = "SENTINEL"
|
||||||
|
|
||||||
|
DEPENDENCIES = {
|
||||||
|
"redis": Version("5.2.0"),
|
||||||
|
"coredis": Version("3.4.0"),
|
||||||
|
"coredis.sentinel": Version("3.4.0"),
|
||||||
|
"valkey": Version("6.0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
wrap_exceptions: bool = False,
|
||||||
|
implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
|
||||||
|
key_prefix: str = RedisStorage.PREFIX,
|
||||||
|
service_name: str | None = None,
|
||||||
|
use_replicas: bool = True,
|
||||||
|
sentinel_kwargs: dict[str, float | str | bool] | None = None,
|
||||||
|
**options: float | str | bool,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
:param uri: url of the form
|
||||||
|
``async+redis+sentinel://host:port,host:port/service_name``
|
||||||
|
|
||||||
|
If the uri schema is ``async+valkey+sentinel`` the implementation used will be from
|
||||||
|
:pypi:`valkey`.
|
||||||
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
||||||
|
:exc:`limits.errors.StorageError` before raising it.
|
||||||
|
:param implementation: Whether to use the client implementation from
|
||||||
|
|
||||||
|
- ``coredis``: :class:`coredis.sentinel.Sentinel`
|
||||||
|
- ``redispy``: :class:`redis.asyncio.sentinel.Sentinel`
|
||||||
|
- ``valkey``: :class:`valkey.asyncio.sentinel.Sentinel`
|
||||||
|
:param key_prefix: the prefix for each key created in redis
|
||||||
|
:param service_name: sentinel service name (if not provided in `uri`)
|
||||||
|
:param use_replicas: Whether to use replicas for read only operations
|
||||||
|
:param sentinel_kwargs: optional arguments to pass as
|
||||||
|
`sentinel_kwargs`` to :class:`coredis.sentinel.Sentinel` or
|
||||||
|
:class:`redis.asyncio.Sentinel`
|
||||||
|
:param options: all remaining keyword arguments are passed
|
||||||
|
directly to the constructor of :class:`coredis.sentinel.Sentinel` or
|
||||||
|
:class:`redis.asyncio.sentinel.Sentinel`
|
||||||
|
:raise ConfigurationError: when the redis library is not available
|
||||||
|
or if the redis primary host cannot be pinged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.service_name = service_name
|
||||||
|
self.use_replicas = use_replicas
|
||||||
|
self.sentinel_kwargs = sentinel_kwargs
|
||||||
|
super().__init__(
|
||||||
|
uri,
|
||||||
|
wrap_exceptions=wrap_exceptions,
|
||||||
|
implementation=implementation,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
**options,
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure_bridge(self) -> None:
|
||||||
|
self.bridge.use_sentinel(
|
||||||
|
self.service_name, self.use_replicas, self.sentinel_kwargs, **self.options
|
||||||
|
)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import urllib
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from limits.util import get_package_data
|
||||||
|
|
||||||
|
|
||||||
|
class RedisBridge(ABC):
|
||||||
|
RES_DIR = "resources/redis/lua_scripts"
|
||||||
|
|
||||||
|
SCRIPT_MOVING_WINDOW = get_package_data(f"{RES_DIR}/moving_window.lua")
|
||||||
|
SCRIPT_ACQUIRE_MOVING_WINDOW = get_package_data(
|
||||||
|
f"{RES_DIR}/acquire_moving_window.lua"
|
||||||
|
)
|
||||||
|
SCRIPT_CLEAR_KEYS = get_package_data(f"{RES_DIR}/clear_keys.lua")
|
||||||
|
SCRIPT_INCR_EXPIRE = get_package_data(f"{RES_DIR}/incr_expire.lua")
|
||||||
|
SCRIPT_SLIDING_WINDOW = get_package_data(f"{RES_DIR}/sliding_window.lua")
|
||||||
|
SCRIPT_ACQUIRE_SLIDING_WINDOW = get_package_data(
|
||||||
|
f"{RES_DIR}/acquire_sliding_window.lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
dependency: ModuleType,
|
||||||
|
key_prefix: str,
|
||||||
|
) -> None:
|
||||||
|
self.uri = uri
|
||||||
|
self.parsed_uri = urllib.parse.urlparse(self.uri)
|
||||||
|
self.dependency = dependency
|
||||||
|
self.parsed_auth = {}
|
||||||
|
self.key_prefix = key_prefix
|
||||||
|
if self.parsed_uri.username:
|
||||||
|
self.parsed_auth["username"] = self.parsed_uri.username
|
||||||
|
if self.parsed_uri.password:
|
||||||
|
self.parsed_auth["password"] = self.parsed_uri.password
|
||||||
|
|
||||||
|
def prefixed_key(self, key: str) -> str:
|
||||||
|
return f"{self.key_prefix}:{key}"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def register_scripts(self) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def use_sentinel(
|
||||||
|
self,
|
||||||
|
service_name: str | None,
|
||||||
|
use_replicas: bool,
|
||||||
|
sentinel_kwargs: dict[str, str | float | bool] | None,
|
||||||
|
**options: str | float | bool,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def use_basic(self, **options: str | float | bool) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def use_cluster(self, **options: str | float | bool) -> None: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def base_exceptions(
|
||||||
|
self,
|
||||||
|
) -> type[Exception] | tuple[type[Exception], ...]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def incr(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get(self, key: str) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def clear(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_moving_window(
|
||||||
|
self, key: str, limit: int, expiry: int
|
||||||
|
) -> tuple[float, int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, previous_key: str, current_key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def acquire_entry(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self,
|
||||||
|
previous_key: str,
|
||||||
|
current_key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_expiry(self, key: str) -> float: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def check(self) -> bool: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def reset(self) -> int | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def lua_reset(self) -> int | None: ...
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from limits.aio.storage.redis.bridge import RedisBridge
|
||||||
|
from limits.errors import ConfigurationError
|
||||||
|
from limits.typing import AsyncCoRedisClient, Callable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import coredis
|
||||||
|
|
||||||
|
|
||||||
|
class CoredisBridge(RedisBridge):
|
||||||
|
DEFAULT_CLUSTER_OPTIONS: dict[str, float | str | bool] = {
|
||||||
|
"max_connections": 1000,
|
||||||
|
}
|
||||||
|
"Default options passed to :class:`coredis.RedisCluster`"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(self) -> type[Exception] | tuple[type[Exception], ...]:
|
||||||
|
return (self.dependency.exceptions.RedisError,)
|
||||||
|
|
||||||
|
def use_sentinel(
|
||||||
|
self,
|
||||||
|
service_name: str | None,
|
||||||
|
use_replicas: bool,
|
||||||
|
sentinel_kwargs: dict[str, str | float | bool] | None,
|
||||||
|
**options: str | float | bool,
|
||||||
|
) -> None:
|
||||||
|
sentinel_configuration = []
|
||||||
|
connection_options = options.copy()
|
||||||
|
|
||||||
|
sep = self.parsed_uri.netloc.find("@") + 1
|
||||||
|
|
||||||
|
for loc in self.parsed_uri.netloc[sep:].split(","):
|
||||||
|
host, port = loc.split(":")
|
||||||
|
sentinel_configuration.append((host, int(port)))
|
||||||
|
service_name = (
|
||||||
|
self.parsed_uri.path.replace("/", "")
|
||||||
|
if self.parsed_uri.path
|
||||||
|
else service_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if service_name is None:
|
||||||
|
raise ConfigurationError("'service_name' not provided")
|
||||||
|
|
||||||
|
self.sentinel = self.dependency.sentinel.Sentinel(
|
||||||
|
sentinel_configuration,
|
||||||
|
sentinel_kwargs={**self.parsed_auth, **(sentinel_kwargs or {})},
|
||||||
|
**{**self.parsed_auth, **connection_options},
|
||||||
|
)
|
||||||
|
self.storage = self.sentinel.primary_for(service_name)
|
||||||
|
self.storage_replica = self.sentinel.replica_for(service_name)
|
||||||
|
self.connection_getter = lambda readonly: (
|
||||||
|
self.storage_replica if readonly and use_replicas else self.storage
|
||||||
|
)
|
||||||
|
|
||||||
|
def use_basic(self, **options: str | float | bool) -> None:
|
||||||
|
if connection_pool := options.pop("connection_pool", None):
|
||||||
|
self.storage = self.dependency.Redis(
|
||||||
|
connection_pool=connection_pool, **options
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.storage = self.dependency.Redis.from_url(self.uri, **options)
|
||||||
|
|
||||||
|
self.connection_getter = lambda _: self.storage
|
||||||
|
|
||||||
|
def use_cluster(self, **options: str | float | bool) -> None:
|
||||||
|
sep = self.parsed_uri.netloc.find("@") + 1
|
||||||
|
cluster_hosts: list[dict[str, int | str]] = []
|
||||||
|
cluster_hosts.extend(
|
||||||
|
{"host": host, "port": int(port)}
|
||||||
|
for loc in self.parsed_uri.netloc[sep:].split(",")
|
||||||
|
if loc
|
||||||
|
for host, port in [loc.split(":")]
|
||||||
|
)
|
||||||
|
self.storage = self.dependency.RedisCluster(
|
||||||
|
startup_nodes=cluster_hosts,
|
||||||
|
**{**self.DEFAULT_CLUSTER_OPTIONS, **self.parsed_auth, **options},
|
||||||
|
)
|
||||||
|
self.connection_getter = lambda _: self.storage
|
||||||
|
|
||||||
|
lua_moving_window: coredis.commands.Script[bytes]
|
||||||
|
lua_acquire_moving_window: coredis.commands.Script[bytes]
|
||||||
|
lua_sliding_window: coredis.commands.Script[bytes]
|
||||||
|
lua_acquire_sliding_window: coredis.commands.Script[bytes]
|
||||||
|
lua_clear_keys: coredis.commands.Script[bytes]
|
||||||
|
lua_incr_expire: coredis.commands.Script[bytes]
|
||||||
|
connection_getter: Callable[[bool], AsyncCoRedisClient]
|
||||||
|
|
||||||
|
def get_connection(self, readonly: bool = False) -> AsyncCoRedisClient:
|
||||||
|
return self.connection_getter(readonly)
|
||||||
|
|
||||||
|
def register_scripts(self) -> None:
|
||||||
|
self.lua_moving_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_MOVING_WINDOW
|
||||||
|
)
|
||||||
|
self.lua_acquire_moving_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_ACQUIRE_MOVING_WINDOW
|
||||||
|
)
|
||||||
|
self.lua_clear_keys = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_CLEAR_KEYS
|
||||||
|
)
|
||||||
|
self.lua_incr_expire = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_INCR_EXPIRE
|
||||||
|
)
|
||||||
|
self.lua_sliding_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_SLIDING_WINDOW
|
||||||
|
)
|
||||||
|
self.lua_acquire_sliding_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_ACQUIRE_SLIDING_WINDOW
|
||||||
|
)
|
||||||
|
|
||||||
|
async def incr(self, key: str, expiry: int, amount: int = 1) -> int:
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
if (value := await self.get_connection().incrby(key, amount)) == amount:
|
||||||
|
await self.get_connection().expire(key, expiry)
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
return int(await self.get_connection(readonly=True).get(key) or 0)
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
await self.get_connection().delete([key])
|
||||||
|
|
||||||
|
async def lua_reset(self) -> int | None:
|
||||||
|
return cast(int, await self.lua_clear_keys.execute([self.prefixed_key("*")]))
|
||||||
|
|
||||||
|
async def get_moving_window(
|
||||||
|
self, key: str, limit: int, expiry: int
|
||||||
|
) -> tuple[float, int]:
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
timestamp = time.time()
|
||||||
|
window = await self.lua_moving_window.execute(
|
||||||
|
[key], [timestamp - expiry, limit]
|
||||||
|
)
|
||||||
|
if window:
|
||||||
|
return float(window[0]), window[1] # type: ignore
|
||||||
|
return timestamp, 0
|
||||||
|
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, previous_key: str, current_key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
previous_key = self.prefixed_key(previous_key)
|
||||||
|
current_key = self.prefixed_key(current_key)
|
||||||
|
|
||||||
|
if window := await self.lua_sliding_window.execute(
|
||||||
|
[previous_key, current_key], [expiry]
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
int(window[0] or 0), # type: ignore
|
||||||
|
max(0, float(window[1] or 0)) / 1000, # type: ignore
|
||||||
|
int(window[2] or 0), # type: ignore
|
||||||
|
max(0, float(window[3] or 0)) / 1000, # type: ignore
|
||||||
|
)
|
||||||
|
return 0, 0.0, 0, 0.0
|
||||||
|
|
||||||
|
async def acquire_entry(
|
||||||
|
self, key: str, limit: int, expiry: int, amount: int = 1
|
||||||
|
) -> bool:
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
timestamp = time.time()
|
||||||
|
acquired = await self.lua_acquire_moving_window.execute(
|
||||||
|
[key], [timestamp, limit, expiry, amount]
|
||||||
|
)
|
||||||
|
|
||||||
|
return bool(acquired)
|
||||||
|
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self,
|
||||||
|
previous_key: str,
|
||||||
|
current_key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
previous_key = self.prefixed_key(previous_key)
|
||||||
|
current_key = self.prefixed_key(current_key)
|
||||||
|
acquired = await self.lua_acquire_sliding_window.execute(
|
||||||
|
[previous_key, current_key], [limit, expiry, amount]
|
||||||
|
)
|
||||||
|
return bool(acquired)
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
return max(await self.get_connection().ttl(key), 0) + time.time()
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
try:
|
||||||
|
await self.get_connection().ping()
|
||||||
|
|
||||||
|
return True
|
||||||
|
except: # noqa
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
prefix = self.prefixed_key("*")
|
||||||
|
keys = await self.storage.keys(prefix)
|
||||||
|
count = 0
|
||||||
|
for key in keys:
|
||||||
|
count += await self.storage.delete([key])
|
||||||
|
return count
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from limits.aio.storage.redis.bridge import RedisBridge
|
||||||
|
from limits.errors import ConfigurationError
|
||||||
|
from limits.typing import AsyncRedisClient, Callable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import redis.commands
|
||||||
|
|
||||||
|
|
||||||
|
class RedispyBridge(RedisBridge):
|
||||||
|
DEFAULT_CLUSTER_OPTIONS: dict[str, float | str | bool] = {
|
||||||
|
"max_connections": 1000,
|
||||||
|
}
|
||||||
|
"Default options passed to :class:`redis.asyncio.RedisCluster`"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_exceptions(self) -> type[Exception] | tuple[type[Exception], ...]:
|
||||||
|
return (self.dependency.RedisError,)
|
||||||
|
|
||||||
|
def use_sentinel(
|
||||||
|
self,
|
||||||
|
service_name: str | None,
|
||||||
|
use_replicas: bool,
|
||||||
|
sentinel_kwargs: dict[str, str | float | bool] | None,
|
||||||
|
**options: str | float | bool,
|
||||||
|
) -> None:
|
||||||
|
sentinel_configuration = []
|
||||||
|
|
||||||
|
connection_options = options.copy()
|
||||||
|
|
||||||
|
sep = self.parsed_uri.netloc.find("@") + 1
|
||||||
|
|
||||||
|
for loc in self.parsed_uri.netloc[sep:].split(","):
|
||||||
|
host, port = loc.split(":")
|
||||||
|
sentinel_configuration.append((host, int(port)))
|
||||||
|
service_name = (
|
||||||
|
self.parsed_uri.path.replace("/", "")
|
||||||
|
if self.parsed_uri.path
|
||||||
|
else service_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if service_name is None:
|
||||||
|
raise ConfigurationError("'service_name' not provided")
|
||||||
|
|
||||||
|
self.sentinel = self.dependency.asyncio.Sentinel(
|
||||||
|
sentinel_configuration,
|
||||||
|
sentinel_kwargs={**self.parsed_auth, **(sentinel_kwargs or {})},
|
||||||
|
**{**self.parsed_auth, **connection_options},
|
||||||
|
)
|
||||||
|
self.storage = self.sentinel.master_for(service_name)
|
||||||
|
self.storage_replica = self.sentinel.slave_for(service_name)
|
||||||
|
self.connection_getter = lambda readonly: (
|
||||||
|
self.storage_replica if readonly and use_replicas else self.storage
|
||||||
|
)
|
||||||
|
|
||||||
|
def use_basic(self, **options: str | float | bool) -> None:
|
||||||
|
if connection_pool := options.pop("connection_pool", None):
|
||||||
|
self.storage = self.dependency.asyncio.Redis(
|
||||||
|
connection_pool=connection_pool, **options
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.storage = self.dependency.asyncio.Redis.from_url(self.uri, **options)
|
||||||
|
|
||||||
|
self.connection_getter = lambda _: self.storage
|
||||||
|
|
||||||
|
def use_cluster(self, **options: str | float | bool) -> None:
|
||||||
|
sep = self.parsed_uri.netloc.find("@") + 1
|
||||||
|
cluster_hosts = []
|
||||||
|
|
||||||
|
for loc in self.parsed_uri.netloc[sep:].split(","):
|
||||||
|
host, port = loc.split(":")
|
||||||
|
cluster_hosts.append(
|
||||||
|
self.dependency.asyncio.cluster.ClusterNode(host=host, port=int(port))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.storage = self.dependency.asyncio.RedisCluster(
|
||||||
|
startup_nodes=cluster_hosts,
|
||||||
|
**{**self.DEFAULT_CLUSTER_OPTIONS, **self.parsed_auth, **options},
|
||||||
|
)
|
||||||
|
self.connection_getter = lambda _: self.storage
|
||||||
|
|
||||||
|
lua_moving_window: redis.commands.core.Script
|
||||||
|
lua_acquire_moving_window: redis.commands.core.Script
|
||||||
|
lua_sliding_window: redis.commands.core.Script
|
||||||
|
lua_acquire_sliding_window: redis.commands.core.Script
|
||||||
|
lua_clear_keys: redis.commands.core.Script
|
||||||
|
lua_incr_expire: redis.commands.core.Script
|
||||||
|
connection_getter: Callable[[bool], AsyncRedisClient]
|
||||||
|
|
||||||
|
def get_connection(self, readonly: bool = False) -> AsyncRedisClient:
|
||||||
|
return self.connection_getter(readonly)
|
||||||
|
|
||||||
|
def register_scripts(self) -> None:
|
||||||
|
# Redis-py uses a slightly different script registration
|
||||||
|
self.lua_moving_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_MOVING_WINDOW
|
||||||
|
)
|
||||||
|
self.lua_acquire_moving_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_ACQUIRE_MOVING_WINDOW
|
||||||
|
)
|
||||||
|
self.lua_clear_keys = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_CLEAR_KEYS
|
||||||
|
)
|
||||||
|
self.lua_incr_expire = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_INCR_EXPIRE
|
||||||
|
)
|
||||||
|
self.lua_sliding_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_SLIDING_WINDOW
|
||||||
|
)
|
||||||
|
self.lua_acquire_sliding_window = self.get_connection().register_script(
|
||||||
|
self.SCRIPT_ACQUIRE_SLIDING_WINDOW
|
||||||
|
)
|
||||||
|
|
||||||
|
async def incr(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
increments the counter for a given rate limit key
|
||||||
|
|
||||||
|
|
||||||
|
:param key: the key to increment
|
||||||
|
:param expiry: amount in seconds for the key to expire in
|
||||||
|
:param amount: the number to increment by
|
||||||
|
"""
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
return cast(int, await self.lua_incr_expire([key], [expiry, amount]))
|
||||||
|
|
||||||
|
async def get(self, key: str) -> int:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param key: the key to get the counter value for
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
return int(await self.get_connection(readonly=True).get(key) or 0)
|
||||||
|
|
||||||
|
async def clear(self, key: str) -> None:
|
||||||
|
"""
|
||||||
|
:param key: the key to clear rate limits for
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
await self.get_connection().delete(key)
|
||||||
|
|
||||||
|
async def lua_reset(self) -> int | None:
|
||||||
|
return cast(int, await self.lua_clear_keys([self.prefixed_key("*")]))
|
||||||
|
|
||||||
|
async def get_moving_window(
|
||||||
|
self, key: str, limit: int, expiry: int
|
||||||
|
) -> tuple[float, int]:
|
||||||
|
"""
|
||||||
|
returns the starting point and the number of entries in the moving
|
||||||
|
window
|
||||||
|
|
||||||
|
:param key: rate limit key
|
||||||
|
:param expiry: expiry of entry
|
||||||
|
:return: (previous count, previous TTL, current count, current TTL)
|
||||||
|
"""
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
timestamp = time.time()
|
||||||
|
window = await self.lua_moving_window([key], [timestamp - expiry, limit])
|
||||||
|
if window:
|
||||||
|
return float(window[0]), window[1]
|
||||||
|
return timestamp, 0
|
||||||
|
|
||||||
|
async def get_sliding_window(
|
||||||
|
self, previous_key: str, current_key: str, expiry: int
|
||||||
|
) -> tuple[int, float, int, float]:
|
||||||
|
if window := await self.lua_sliding_window(
|
||||||
|
[self.prefixed_key(previous_key), self.prefixed_key(current_key)], [expiry]
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
int(window[0] or 0),
|
||||||
|
max(0, float(window[1] or 0)) / 1000,
|
||||||
|
int(window[2] or 0),
|
||||||
|
max(0, float(window[3] or 0)) / 1000,
|
||||||
|
)
|
||||||
|
return 0, 0.0, 0, 0.0
|
||||||
|
|
||||||
|
async def acquire_entry(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
:param key: rate limit key to acquire an entry in
|
||||||
|
:param limit: amount of entries allowed
|
||||||
|
:param expiry: expiry of the entry
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
timestamp = time.time()
|
||||||
|
acquired = await self.lua_acquire_moving_window(
|
||||||
|
[key], [timestamp, limit, expiry, amount]
|
||||||
|
)
|
||||||
|
|
||||||
|
return bool(acquired)
|
||||||
|
|
||||||
|
async def acquire_sliding_window_entry(
|
||||||
|
self,
|
||||||
|
previous_key: str,
|
||||||
|
current_key: str,
|
||||||
|
limit: int,
|
||||||
|
expiry: int,
|
||||||
|
amount: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
previous_key = self.prefixed_key(previous_key)
|
||||||
|
current_key = self.prefixed_key(current_key)
|
||||||
|
acquired = await self.lua_acquire_sliding_window(
|
||||||
|
[previous_key, current_key], [limit, expiry, amount]
|
||||||
|
)
|
||||||
|
return bool(acquired)
|
||||||
|
|
||||||
|
async def get_expiry(self, key: str) -> float:
|
||||||
|
"""
|
||||||
|
:param key: the key to get the expiry for
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = self.prefixed_key(key)
|
||||||
|
return max(await self.get_connection().ttl(key), 0) + time.time()
|
||||||
|
|
||||||
|
async def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
check if storage is healthy
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.get_connection().ping()
|
||||||
|
|
||||||
|
return True
|
||||||
|
except: # noqa
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def reset(self) -> int | None:
|
||||||
|
prefix = self.prefixed_key("*")
|
||||||
|
keys = await self.storage.keys(
|
||||||
|
prefix, target_nodes=self.dependency.asyncio.cluster.RedisCluster.ALL_NODES
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
for key in keys:
|
||||||
|
count += await self.storage.delete(key)
|
||||||
|
return count
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .redispy import RedispyBridge
|
||||||
|
|
||||||
|
|
||||||
|
class ValkeyBridge(RedispyBridge):
|
||||||
|
@property
|
||||||
|
def base_exceptions(self) -> type[Exception] | tuple[type[Exception], ...]:
|
||||||
|
return (self.dependency.ValkeyError,)
|
||||||
331
buffteks/lib/python3.11/site-packages/limits/aio/strategies.py
Normal file
331
buffteks/lib/python3.11/site-packages/limits/aio/strategies.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
Asynchronous rate limiting strategies
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from math import floor, inf
|
||||||
|
|
||||||
|
from deprecated.sphinx import versionadded
|
||||||
|
|
||||||
|
from ..limits import RateLimitItem
|
||||||
|
from ..storage import StorageTypes
|
||||||
|
from ..typing import cast
|
||||||
|
from ..util import WindowStats
|
||||||
|
from .storage import MovingWindowSupport, Storage
|
||||||
|
from .storage.base import SlidingWindowCounterSupport
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter(ABC):
|
||||||
|
def __init__(self, storage: StorageTypes):
|
||||||
|
assert isinstance(storage, Storage)
|
||||||
|
self.storage: Storage = storage
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def hit(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Consume the rate limit
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:param cost: The cost of this hit, default 1
|
||||||
|
|
||||||
|
:return: True if ``cost`` could be deducted from the rate limit without exceeding it
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def test(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the rate limit can be consumed
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:param cost: The expected cost to be consumed, default 1
|
||||||
|
|
||||||
|
:return: True if the rate limit is not depleted
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_window_stats(
|
||||||
|
self, item: RateLimitItem, *identifiers: str
|
||||||
|
) -> WindowStats:
|
||||||
|
"""
|
||||||
|
Query the reset time and remaining amount for the limit
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:return: (reset time, remaining))
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def clear(self, item: RateLimitItem, *identifiers: str) -> None:
|
||||||
|
return await self.storage.clear(item.key_for(*identifiers))
|
||||||
|
|
||||||
|
|
||||||
|
class MovingWindowRateLimiter(RateLimiter):
|
||||||
|
"""
|
||||||
|
Reference: :ref:`strategies:moving window`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, storage: StorageTypes) -> None:
|
||||||
|
if not (
|
||||||
|
hasattr(storage, "acquire_entry") or hasattr(storage, "get_moving_window")
|
||||||
|
):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"MovingWindowRateLimiting is not implemented for storage "
|
||||||
|
f"of type {storage.__class__}"
|
||||||
|
)
|
||||||
|
super().__init__(storage)
|
||||||
|
|
||||||
|
async def hit(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Consume the rate limit
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:param cost: The cost of this hit, default 1
|
||||||
|
|
||||||
|
:return: True if ``cost`` could be deducted from the rate limit without exceeding it
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await cast(MovingWindowSupport, self.storage).acquire_entry(
|
||||||
|
item.key_for(*identifiers), item.amount, item.get_expiry(), amount=cost
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the rate limit can be consumed
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:param cost: The expected cost to be consumed, default 1
|
||||||
|
|
||||||
|
:return: True if the rate limit is not depleted
|
||||||
|
"""
|
||||||
|
res = await cast(MovingWindowSupport, self.storage).get_moving_window(
|
||||||
|
item.key_for(*identifiers),
|
||||||
|
item.amount,
|
||||||
|
item.get_expiry(),
|
||||||
|
)
|
||||||
|
amount = res[1]
|
||||||
|
|
||||||
|
return amount <= item.amount - cost
|
||||||
|
|
||||||
|
async def get_window_stats(
|
||||||
|
self, item: RateLimitItem, *identifiers: str
|
||||||
|
) -> WindowStats:
|
||||||
|
"""
|
||||||
|
returns the number of requests remaining within this limit.
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:return: (reset time, remaining)
|
||||||
|
"""
|
||||||
|
window_start, window_items = await cast(
|
||||||
|
MovingWindowSupport, self.storage
|
||||||
|
).get_moving_window(item.key_for(*identifiers), item.amount, item.get_expiry())
|
||||||
|
reset = window_start + item.get_expiry()
|
||||||
|
|
||||||
|
return WindowStats(reset, item.amount - window_items)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowRateLimiter(RateLimiter):
|
||||||
|
"""
|
||||||
|
Reference: :ref:`strategies:fixed window`
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def hit(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Consume the rate limit
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:param cost: The cost of this hit, default 1
|
||||||
|
|
||||||
|
:return: True if ``cost`` could be deducted from the rate limit without exceeding it
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
await self.storage.incr(
|
||||||
|
item.key_for(*identifiers),
|
||||||
|
item.get_expiry(),
|
||||||
|
amount=cost,
|
||||||
|
)
|
||||||
|
<= item.amount
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the rate limit can be consumed
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:param cost: The expected cost to be consumed, default 1
|
||||||
|
|
||||||
|
:return: True if the rate limit is not depleted
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
await self.storage.get(item.key_for(*identifiers)) < item.amount - cost + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_window_stats(
|
||||||
|
self, item: RateLimitItem, *identifiers: str
|
||||||
|
) -> WindowStats:
|
||||||
|
"""
|
||||||
|
Query the reset time and remaining amount for the limit
|
||||||
|
|
||||||
|
:param item: the rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify the
|
||||||
|
limit
|
||||||
|
:return: reset time, remaining
|
||||||
|
"""
|
||||||
|
remaining = max(
|
||||||
|
0,
|
||||||
|
item.amount - await self.storage.get(item.key_for(*identifiers)),
|
||||||
|
)
|
||||||
|
reset = await self.storage.get_expiry(item.key_for(*identifiers))
|
||||||
|
|
||||||
|
return WindowStats(reset, remaining)
|
||||||
|
|
||||||
|
|
||||||
|
@versionadded(version="4.1")
|
||||||
|
class SlidingWindowCounterRateLimiter(RateLimiter):
|
||||||
|
"""
|
||||||
|
Reference: :ref:`strategies:sliding window counter`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, storage: StorageTypes):
|
||||||
|
if not hasattr(storage, "get_sliding_window") or not hasattr(
|
||||||
|
storage, "acquire_sliding_window_entry"
|
||||||
|
):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"SlidingWindowCounterRateLimiting is not implemented for storage "
|
||||||
|
f"of type {storage.__class__}"
|
||||||
|
)
|
||||||
|
super().__init__(storage)
|
||||||
|
|
||||||
|
def _weighted_count(
|
||||||
|
self,
|
||||||
|
item: RateLimitItem,
|
||||||
|
previous_count: int,
|
||||||
|
previous_expires_in: float,
|
||||||
|
current_count: int,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Return the approximated by weighting the previous window count and adding the current window count.
|
||||||
|
"""
|
||||||
|
return previous_count * previous_expires_in / item.get_expiry() + current_count
|
||||||
|
|
||||||
|
async def hit(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Consume the rate limit
|
||||||
|
|
||||||
|
:param item: The rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify this
|
||||||
|
instance of the limit
|
||||||
|
:param cost: The cost of this hit, default 1
|
||||||
|
|
||||||
|
:return: True if ``cost`` could be deducted from the rate limit without exceeding it
|
||||||
|
"""
|
||||||
|
return await cast(
|
||||||
|
SlidingWindowCounterSupport, self.storage
|
||||||
|
).acquire_sliding_window_entry(
|
||||||
|
item.key_for(*identifiers),
|
||||||
|
item.amount,
|
||||||
|
item.get_expiry(),
|
||||||
|
cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the rate limit can be consumed
|
||||||
|
|
||||||
|
:param item: The rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify this
|
||||||
|
instance of the limit
|
||||||
|
:param cost: The expected cost to be consumed, default 1
|
||||||
|
|
||||||
|
:return: True if the rate limit is not depleted
|
||||||
|
"""
|
||||||
|
|
||||||
|
previous_count, previous_expires_in, current_count, _ = await cast(
|
||||||
|
SlidingWindowCounterSupport, self.storage
|
||||||
|
).get_sliding_window(item.key_for(*identifiers), item.get_expiry())
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._weighted_count(
|
||||||
|
item, previous_count, previous_expires_in, current_count
|
||||||
|
)
|
||||||
|
< item.amount - cost + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_window_stats(
|
||||||
|
self, item: RateLimitItem, *identifiers: str
|
||||||
|
) -> WindowStats:
|
||||||
|
"""
|
||||||
|
Query the reset time and remaining amount for the limit.
|
||||||
|
|
||||||
|
:param item: The rate limit item
|
||||||
|
:param identifiers: variable list of strings to uniquely identify this
|
||||||
|
instance of the limit
|
||||||
|
:return: (reset time, remaining)
|
||||||
|
"""
|
||||||
|
|
||||||
|
(
|
||||||
|
previous_count,
|
||||||
|
previous_expires_in,
|
||||||
|
current_count,
|
||||||
|
current_expires_in,
|
||||||
|
) = await cast(SlidingWindowCounterSupport, self.storage).get_sliding_window(
|
||||||
|
item.key_for(*identifiers), item.get_expiry()
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining = max(
|
||||||
|
0,
|
||||||
|
item.amount
|
||||||
|
- floor(
|
||||||
|
self._weighted_count(
|
||||||
|
item, previous_count, previous_expires_in, current_count
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if not (previous_count or current_count):
|
||||||
|
return WindowStats(now, remaining)
|
||||||
|
|
||||||
|
expiry = item.get_expiry()
|
||||||
|
|
||||||
|
previous_reset_in, current_reset_in = inf, inf
|
||||||
|
if previous_count:
|
||||||
|
previous_reset_in = previous_expires_in % (expiry / previous_count)
|
||||||
|
if current_count:
|
||||||
|
current_reset_in = current_expires_in % expiry
|
||||||
|
|
||||||
|
return WindowStats(now + min(previous_reset_in, current_reset_in), remaining)
|
||||||
|
|
||||||
|
async def clear(self, item: RateLimitItem, *identifiers: str) -> None:
|
||||||
|
return await cast(
|
||||||
|
SlidingWindowCounterSupport, self.storage
|
||||||
|
).clear_sliding_window(item.key_for(*identifiers), item.get_expiry())
|
||||||
|
|
||||||
|
|
||||||
|
STRATEGIES = {
|
||||||
|
"sliding-window-counter": SlidingWindowCounterRateLimiter,
|
||||||
|
"fixed-window": FixedWindowRateLimiter,
|
||||||
|
"moving-window": MovingWindowRateLimiter,
|
||||||
|
}
|
||||||
30
buffteks/lib/python3.11/site-packages/limits/errors.py
Normal file
30
buffteks/lib/python3.11/site-packages/limits/errors.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
errors and exceptions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(Exception):
|
||||||
|
"""
|
||||||
|
Error raised when a configuration problem is encountered
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrentUpdateError(Exception):
|
||||||
|
"""
|
||||||
|
Error raised when an update to limit fails due to concurrent
|
||||||
|
updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, key: str, attempts: int) -> None:
|
||||||
|
super().__init__(f"Unable to update {key} after {attempts} retries")
|
||||||
|
|
||||||
|
|
||||||
|
class StorageError(Exception):
|
||||||
|
"""
|
||||||
|
Error raised when an error is encountered in a storage
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, storage_error: Exception) -> None:
|
||||||
|
self.storage_error = storage_error
|
||||||
196
buffteks/lib/python3.11/site-packages/limits/limits.py
Normal file
196
buffteks/lib/python3.11/site-packages/limits/limits.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
""" """
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import total_ordering
|
||||||
|
|
||||||
|
from limits.typing import ClassVar, NamedTuple, cast
|
||||||
|
|
||||||
|
|
||||||
|
def safe_string(value: bytes | str | int | float) -> str:
|
||||||
|
"""
|
||||||
|
normalize a byte/str/int or float to a str
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.decode()
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class Granularity(NamedTuple):
|
||||||
|
seconds: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
TIME_TYPES = dict(
|
||||||
|
day=Granularity(60 * 60 * 24, "day"),
|
||||||
|
month=Granularity(60 * 60 * 24 * 30, "month"),
|
||||||
|
year=Granularity(60 * 60 * 24 * 30 * 12, "year"),
|
||||||
|
hour=Granularity(60 * 60, "hour"),
|
||||||
|
minute=Granularity(60, "minute"),
|
||||||
|
second=Granularity(1, "second"),
|
||||||
|
)
|
||||||
|
|
||||||
|
GRANULARITIES: dict[str, type[RateLimitItem]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitItemMeta(type):
|
||||||
|
def __new__(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
parents: tuple[type, ...],
|
||||||
|
dct: dict[str, Granularity | list[str]],
|
||||||
|
) -> RateLimitItemMeta:
|
||||||
|
if "__slots__" not in dct:
|
||||||
|
dct["__slots__"] = []
|
||||||
|
granularity = super().__new__(cls, name, parents, dct)
|
||||||
|
|
||||||
|
if "GRANULARITY" in dct:
|
||||||
|
GRANULARITIES[dct["GRANULARITY"][1]] = cast(
|
||||||
|
type[RateLimitItem], granularity
|
||||||
|
)
|
||||||
|
|
||||||
|
return granularity
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
@total_ordering
|
||||||
|
class RateLimitItem(metaclass=RateLimitItemMeta):
|
||||||
|
"""
|
||||||
|
defines a Rate limited resource which contains the characteristic
|
||||||
|
namespace, amount and granularity multiples of the rate limiting window.
|
||||||
|
|
||||||
|
:param amount: the rate limit amount
|
||||||
|
:param multiples: multiple of the 'per' :attr:`GRANULARITY`
|
||||||
|
(e.g. 'n' per 'm' seconds)
|
||||||
|
:param namespace: category for the specific rate limit
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ["namespace", "amount", "multiples"]
|
||||||
|
|
||||||
|
GRANULARITY: ClassVar[Granularity]
|
||||||
|
"""
|
||||||
|
A tuple describing the granularity of this limit as
|
||||||
|
(number of seconds, name)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, amount: int, multiples: int | None = 1, namespace: str = "LIMITER"
|
||||||
|
):
|
||||||
|
self.namespace = namespace
|
||||||
|
self.amount = int(amount)
|
||||||
|
self.multiples = int(multiples or 1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_granularity_string(cls, granularity_string: str) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if this instance matches a *granularity_string*
|
||||||
|
of type ``n per hour``, ``n per minute`` etc,
|
||||||
|
by comparing with :attr:`GRANULARITY`
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
return granularity_string.lower() in {
|
||||||
|
cls.GRANULARITY.name,
|
||||||
|
f"{cls.GRANULARITY.name}s", # allow plurals like days, hours etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_expiry(self) -> int:
|
||||||
|
"""
|
||||||
|
:return: the duration the limit is enforced for in seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.GRANULARITY.seconds * self.multiples
|
||||||
|
|
||||||
|
def key_for(self, *identifiers: bytes | str | int | float) -> str:
|
||||||
|
"""
|
||||||
|
Constructs a key for the current limit and any additional
|
||||||
|
identifiers provided.
|
||||||
|
|
||||||
|
:param identifiers: a list of strings to append to the key
|
||||||
|
:return: a string key identifying this resource with
|
||||||
|
each identifier separated with a '/' delimiter.
|
||||||
|
"""
|
||||||
|
remainder = "/".join(
|
||||||
|
[safe_string(k) for k in identifiers]
|
||||||
|
+ [
|
||||||
|
safe_string(self.amount),
|
||||||
|
safe_string(self.multiples),
|
||||||
|
self.GRANULARITY.name,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{self.namespace}/{remainder}"
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if isinstance(other, RateLimitItem):
|
||||||
|
return (
|
||||||
|
self.amount == other.amount
|
||||||
|
and self.GRANULARITY == other.GRANULARITY
|
||||||
|
and self.multiples == other.multiples
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"{self.amount} per {self.multiples} {self.GRANULARITY.name}"
|
||||||
|
|
||||||
|
def __lt__(self, other: RateLimitItem) -> bool:
|
||||||
|
return self.GRANULARITY.seconds < other.GRANULARITY.seconds
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash((self.namespace, self.amount, self.multiples, self.GRANULARITY))
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitItemPerYear(RateLimitItem):
|
||||||
|
"""
|
||||||
|
per year rate limited resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRANULARITY = TIME_TYPES["year"]
|
||||||
|
"""A year"""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitItemPerMonth(RateLimitItem):
|
||||||
|
"""
|
||||||
|
per month rate limited resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRANULARITY = TIME_TYPES["month"]
|
||||||
|
"""A month"""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitItemPerDay(RateLimitItem):
|
||||||
|
"""
|
||||||
|
per day rate limited resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRANULARITY = TIME_TYPES["day"]
|
||||||
|
"""A day"""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitItemPerHour(RateLimitItem):
|
||||||
|
"""
|
||||||
|
per hour rate limited resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRANULARITY = TIME_TYPES["hour"]
|
||||||
|
"""An hour"""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitItemPerMinute(RateLimitItem):
|
||||||
|
"""
|
||||||
|
per minute rate limited resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRANULARITY = TIME_TYPES["minute"]
|
||||||
|
"""A minute"""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitItemPerSecond(RateLimitItem):
|
||||||
|
"""
|
||||||
|
per second rate limited resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRANULARITY = TIME_TYPES["second"]
|
||||||
|
"""A second"""
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
local timestamp = tonumber(ARGV[1])
|
||||||
|
local limit = tonumber(ARGV[2])
|
||||||
|
local expiry = tonumber(ARGV[3])
|
||||||
|
local amount = tonumber(ARGV[4])
|
||||||
|
|
||||||
|
if amount > limit then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local entry = redis.call('lindex', KEYS[1], limit - amount)
|
||||||
|
|
||||||
|
if entry and tonumber(entry) >= timestamp - expiry then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local entries = {}
|
||||||
|
for i = 1, amount do
|
||||||
|
entries[i] = timestamp
|
||||||
|
end
|
||||||
|
|
||||||
|
for i=1,#entries,5000 do
|
||||||
|
redis.call('lpush', KEYS[1], unpack(entries, i, math.min(i+4999, #entries)))
|
||||||
|
end
|
||||||
|
redis.call('ltrim', KEYS[1], 0, limit - 1)
|
||||||
|
redis.call('expire', KEYS[1], expiry)
|
||||||
|
|
||||||
|
return true
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Time is in milliseconds in this script: TTL, expiry...
|
||||||
|
|
||||||
|
local limit = tonumber(ARGV[1])
|
||||||
|
local expiry = tonumber(ARGV[2]) * 1000
|
||||||
|
local amount = tonumber(ARGV[3])
|
||||||
|
|
||||||
|
if amount > limit then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_ttl = tonumber(redis.call('pttl', KEYS[2]))
|
||||||
|
|
||||||
|
if current_ttl > 0 and current_ttl < expiry then
|
||||||
|
-- Current window expired, shift it to the previous window
|
||||||
|
redis.call('rename', KEYS[2], KEYS[1])
|
||||||
|
redis.call('set', KEYS[2], 0, 'PX', current_ttl + expiry)
|
||||||
|
end
|
||||||
|
|
||||||
|
local previous_count = tonumber(redis.call('get', KEYS[1])) or 0
|
||||||
|
local previous_ttl = tonumber(redis.call('pttl', KEYS[1])) or 0
|
||||||
|
local current_count = tonumber(redis.call('get', KEYS[2])) or 0
|
||||||
|
current_ttl = tonumber(redis.call('pttl', KEYS[2])) or 0
|
||||||
|
|
||||||
|
-- If the values don't exist yet, consider the TTL is 0
|
||||||
|
if previous_ttl <= 0 then
|
||||||
|
previous_ttl = 0
|
||||||
|
end
|
||||||
|
if current_ttl <= 0 then
|
||||||
|
current_ttl = 0
|
||||||
|
end
|
||||||
|
local weighted_count = math.floor(previous_count * previous_ttl / expiry) + current_count
|
||||||
|
|
||||||
|
if (weighted_count + amount) > limit then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If the current counter exists, increase its value
|
||||||
|
if redis.call('exists', KEYS[2]) == 1 then
|
||||||
|
redis.call('incrby', KEYS[2], amount)
|
||||||
|
else
|
||||||
|
-- Otherwise, set the value with twice the expiry time
|
||||||
|
redis.call('set', KEYS[2], amount, 'PX', expiry * 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
local keys = redis.call('keys', KEYS[1])
|
||||||
|
local res = 0
|
||||||
|
|
||||||
|
for i=1,#keys,5000 do
|
||||||
|
res = res + redis.call(
|
||||||
|
'del', unpack(keys, i, math.min(i+4999, #keys))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return res
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
local current
|
||||||
|
local amount = tonumber(ARGV[2])
|
||||||
|
current = redis.call("incrby", KEYS[1], amount)
|
||||||
|
|
||||||
|
if tonumber(current) == amount then
|
||||||
|
redis.call("expire", KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
|
||||||
|
return current
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
local len = tonumber(ARGV[2])
|
||||||
|
local expiry = tonumber(ARGV[1])
|
||||||
|
|
||||||
|
-- Binary search to find the oldest valid entry in the window
|
||||||
|
local function oldest_entry(high, target)
|
||||||
|
local low = 0
|
||||||
|
local result = nil
|
||||||
|
|
||||||
|
while low <= high do
|
||||||
|
local mid = math.floor((low + high) / 2)
|
||||||
|
local val = tonumber(redis.call('lindex', KEYS[1], mid))
|
||||||
|
|
||||||
|
if val and val >= target then
|
||||||
|
result = mid
|
||||||
|
low = mid + 1
|
||||||
|
else
|
||||||
|
high = mid - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local index = oldest_entry(len - 1, expiry)
|
||||||
|
|
||||||
|
if index then
|
||||||
|
local count = index + 1
|
||||||
|
local oldest = tonumber(redis.call('lindex', KEYS[1], index))
|
||||||
|
return {tostring(oldest), count}
|
||||||
|
end
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
local expiry = tonumber(ARGV[1]) * 1000
|
||||||
|
local previous_count = redis.call('get', KEYS[1])
|
||||||
|
local previous_ttl = redis.call('pttl', KEYS[1])
|
||||||
|
local current_count = redis.call('get', KEYS[2])
|
||||||
|
local current_ttl = redis.call('pttl', KEYS[2])
|
||||||
|
|
||||||
|
if current_ttl > 0 and current_ttl < expiry then
|
||||||
|
-- Current window expired, shift it to the previous window
|
||||||
|
redis.call('rename', KEYS[2], KEYS[1])
|
||||||
|
redis.call('set', KEYS[2], 0, 'PX', current_ttl + expiry)
|
||||||
|
previous_count = redis.call('get', KEYS[1])
|
||||||
|
previous_ttl = redis.call('pttl', KEYS[1])
|
||||||
|
current_count = redis.call('get', KEYS[2])
|
||||||
|
current_ttl = redis.call('pttl', KEYS[2])
|
||||||
|
end
|
||||||
|
|
||||||
|
return {previous_count, previous_ttl, current_count, current_ttl}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user