feat: Add announcements endpoints (#91)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Alexandre Teles (afterSt0rm) <alexandre.teles@ufba.br>
This commit is contained in:
oSumAtrIX 2023-10-11 03:32:53 +02:00 committed by GitHub
parent c65b43aff3
commit 8583e2a2bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1628 additions and 292 deletions

54
.github/workflows/codeql.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: "CodeQL"
on:
push:
branches: [dev]
pull_request:
types: [opened, reopened, edited, synchronize]
schedule:
- cron: "29 5 * * 5"
workflow_dispatch:
jobs:
analyze:
name: Analyze
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["python"]
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11.6"
- name: Install project dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ];
then pip install -r requirements.txt;
fi
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

@ -26,7 +26,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.11.4" python-version: "3.11.6"
- name: Install project dependencies - name: Install project dependencies
run: | run: |

View File

@ -1,45 +0,0 @@
name: "Qodana | Code Quality Scan and Static Analysis"
on:
push:
branches: [dev]
pull_request:
types: [opened, reopened, edited, synchronize]
workflow_dispatch:
env:
default_branch: dev
jobs:
qodana:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: "Checkout"
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11.4"
- name: Install project dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ];
then pip install -r requirements.txt;
fi
- name: "Qodana Scan"
uses: JetBrains/qodana-action@v2023.2.8
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
with:
args: --baseline,qodana.sarif.json
- name: "Upload Qodana Report"
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: ${{ runner.temp }}/qodana/results/qodana.sarif.json

1
.gitignore vendored
View File

@ -161,3 +161,4 @@ cython_debug/
# custom # custom
env.sh env.sh
persistance/database.db

View File

@ -16,11 +16,5 @@ repos:
hooks: hooks:
- id: black - id: black
language_version: python3.11 language_version: python3.11
- repo: https://github.com/pryorda/dockerfilelint-precommit-hooks
rev: v0.1.0
hooks:
- id: dockerfilelint
stages: [commit]
ci: ci:
autoupdate_branch: "dev" autoupdate_branch: "dev"

View File

@ -4,7 +4,7 @@ FROM python:3.11-slim as dependencies
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends gcc \ apt-get install -y --no-install-recommends gcc git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv RUN python -m venv /opt/venv
@ -28,6 +28,8 @@ ENV PATH="/opt/venv/bin:$PATH"
COPY --from=dependencies /opt/venv /opt/venv COPY --from=dependencies /opt/venv /opt/venv
COPY . . COPY . .
VOLUME persistance
CMD docker/run-backend.sh CMD docker/run-backend.sh
HEALTHCHECK CMD docker/run-healthcheck.sh HEALTHCHECK CMD docker/run-healthcheck.sh

View File

@ -20,7 +20,11 @@ To run this API, you need Python 3.11.x. You can install the dependencies with p
poetry install poetry install
``` ```
Create an environment variable called `GITHUB_TOKEN` with a valid GitHub token with read access to public repositories. Create the following environment variables:
- `GITHUB_TOKEN` with a valid GitHub token with read access to public repositories
- `SECRET_KEY` to salt login sessions
- `USERNAME` & `PASSWORD` to initialize the database with a user to login with to authenticated endpoints
Then, you can run the API in development mode with: Then, you can run the API in development mode with:

View File

@ -7,5 +7,9 @@ from api.socials import socials
from api.info import info from api.info import info
from api.compat import github as compat from api.compat import github as compat
from api.donations import donations from api.donations import donations
from api.announcements import announcements
from api.login import login
api = Blueprint.group(ping, github, info, socials, donations, compat, url_prefix="/") api = Blueprint.group(
login, ping, github, info, socials, donations, announcements, compat, url_prefix="/"
)

242
api/announcements.py Normal file
View File

@ -0,0 +1,242 @@
"""
This module provides a blueprint for the announcements endpoint.
Routes:
- GET /announcements: Get a list of announcements from all channels.
- GET /announcements/<channel:str>: Get a list of announcement from a channel.
- GET /announcements/latest: Get the latest announcement.
- GET /announcements/<channel:str>/latest: Get the latest announcement from a channel.
- POST /announcements/<channel:str>: Create an announcement.
- DELETE /announcements/<announcement_id:int>: Delete an announcement.
"""
import datetime
from sanic import Blueprint, Request
from sanic.response import JSONResponse, json
from sanic_ext import openapi
from data.database import Session
from data.models import AnnouncementDbModel, AttachmentDbModel
import sanic_beskar
from api.models.announcements import AnnouncementResponseModel
from config import api_version
from limiter import limiter
announcements: Blueprint = Blueprint("announcements", version=api_version)
@announcements.get("/announcements")
@openapi.definition(
summary="Get a list of announcements",
response=[[AnnouncementResponseModel]],
)
async def get_announcements(request: Request) -> JSONResponse:
"""
Retrieve a list of announcements.
**Returns:**
- JSONResponse: A Sanic JSONResponse object containing a list of announcements from all channels.
"""
session = Session()
announcements = [
AnnouncementResponseModel.to_response(announcement)
for announcement in session.query(AnnouncementDbModel).all()
]
session.close()
return json(announcements, status=200)
@announcements.get("/announcements/<channel:str>")
@openapi.definition(
summary="Get a list of announcements from a channel",
response=[[AnnouncementResponseModel]],
)
async def get_announcements_for_channel(request: Request, channel: str) -> JSONResponse:
"""
Retrieve a list of announcements from a channel.
**Args:**
- channel (str): The channel to retrieve announcements from.
**Returns:**
- JSONResponse: A Sanic JSONResponse object containing a list of announcements from a channel.
"""
session = Session()
announcements = [
AnnouncementResponseModel.to_response(announcement)
for announcement in session.query(AnnouncementDbModel)
.filter_by(channel=channel)
.all()
]
session.close()
return json(announcements, status=200)
@announcements.get("/announcements/latest")
@openapi.definition(
summary="Get the latest announcement",
response=AnnouncementResponseModel,
)
async def get_latest_announcement(request: Request) -> JSONResponse:
"""
Retrieve the latest announcement.
**Returns:**
- JSONResponse: A Sanic JSONResponse object containing the latest announcement.
"""
session = Session()
announcement = (
session.query(AnnouncementDbModel)
.order_by(AnnouncementDbModel.id.desc())
.first()
)
if not announcement:
return json({"error": "No announcement found"}, status=404)
announcement_response = AnnouncementResponseModel.to_response(announcement)
session.close()
return json(announcement_response, status=200)
# for specific channel
@announcements.get("/announcements/<channel:str>/latest")
@openapi.definition(
summary="Get the latest announcement from a channel",
response=AnnouncementResponseModel,
)
async def get_latest_announcement_for_channel(
request: Request, channel: str
) -> JSONResponse:
"""
Retrieve the latest announcement from a channel.
**Args:**
- channel (str): The channel to retrieve the latest announcement from.
**Returns:**
- JSONResponse: A Sanic JSONResponse object containing the latest announcement from a channel.
"""
session = Session()
announcement = (
session.query(AnnouncementDbModel)
.filter_by(channel=channel)
.order_by(AnnouncementDbModel.id.desc())
.first()
)
if not announcement:
return json({"error": "No announcement found"}, status=404)
announcement_response = AnnouncementResponseModel.to_response(announcement)
session.close()
return json(announcement_response, status=200)
@announcements.post("/announcements/<channel:str>")
@limiter.limit("16 per hour")
@sanic_beskar.auth_required
@openapi.definition(
summary="Create an announcement",
body=AnnouncementResponseModel,
response=AnnouncementResponseModel,
)
async def post_announcement(request: Request, channel: str) -> JSONResponse:
"""
Create an announcement.
**Args:**
- author (str | None): The author of the announcement.
- title (str): The title of the announcement.
- content (ContentFields | None): The content of the announcement.
- channel (str): The channel to create the announcement in.
- nevel (int | None): The severity of the announcement.
"""
session = Session()
if not request.json:
return json({"error": "Missing request body"}, status=400)
content = request.json.get("content", None)
author = request.json.get("author", None)
title = request.json.get("title")
message = content["message"] if content and "message" in content else None
attachments = (
list(
map(
lambda url: AttachmentDbModel(attachment_url=url),
content["attachments"],
)
)
if content and "attachments" in content
else []
)
level = request.json.get("level", None)
created_at = datetime.datetime.now()
announcement = AnnouncementDbModel(
author=author,
title=title,
message=message,
attachments=attachments,
channel=channel,
created_at=created_at,
level=level,
)
session.add(announcement)
session.commit()
session.close()
return json({}, status=200)
@announcements.delete("/announcements/<announcement_id:int>")
@sanic_beskar.auth_required
@openapi.definition(
summary="Delete an announcement",
)
async def delete_announcement(request: Request, announcement_id: int) -> JSONResponse:
"""
Delete an announcement.
**Args:**
- announcement_id (int): The ID of the announcement to delete.
**Exceptions:**
- 404: Announcement not found.
"""
session = Session()
announcement = (
session.query(AnnouncementDbModel).filter_by(id=announcement_id).first()
)
if not announcement:
return json({"error": "Announcement not found"}, status=404)
session.delete(announcement)
session.commit()
session.close()
return json({}, status=200)

54
api/login.py Normal file
View File

@ -0,0 +1,54 @@
"""
This module provides a blueprint for the login endpoint.
Routes:
- POST /login: Login to the API
"""
from sanic import Blueprint, Request
from sanic.response import JSONResponse, json
from sanic_ext import openapi
from sanic_beskar.exceptions import AuthenticationError
from auth import beskar
from limiter import limiter
from config import api_version
login: Blueprint = Blueprint("login", version=api_version)
@login.post("/login")
@openapi.definition(
summary="Login to the API",
)
@limiter.limit("3 per hour")
async def login_user(request: Request) -> JSONResponse:
"""
Login to the API.
**Args:**
- username (str): The username of the user to login.
- password (str): The password of the user to login.
**Returns:**
- JSONResponse: A Sanic JSONResponse object containing the access token.
"""
req = request.json
username = req.get("username", None)
password = req.get("password", None)
if not username or not password:
return json({"error": "Missing username or password"}, status=400)
try:
user = await beskar.authenticate(username, password)
except AuthenticationError:
return json({"error": "Invalid username or password"}, status=403)
if not user:
return json({"error": "Invalid username or password"}, status=403)
ret = {"access_token": await beskar.encode_token(user)}
return json(ret, status=200)

View File

@ -0,0 +1,37 @@
from data.models import AnnouncementDbModel
class ContentFields(dict):
message: str | None
attachment_urls: list[str] | None
class AnnouncementResponseModel(dict):
id: int
author: str | None
title: str
content: ContentFields | None
channel: str
created_at: str
level: int | None
@staticmethod
def to_response(announcement: AnnouncementDbModel):
response = AnnouncementResponseModel(
id=announcement.id,
author=announcement.author,
title=announcement.title,
content=ContentFields(
message=announcement.message,
attachment_urls=[
attachment.attachment_url for attachment in announcement.attachments
],
)
if announcement.message or announcement.attachments
else None,
channel=announcement.channel,
created_at=str(announcement.created_at),
level=announcement.level,
)
return response

10
app.py
View File

@ -6,6 +6,9 @@ from sanic_ext import Config
from api import api from api import api
from config import * from config import *
from limiter import configure_limiter
from auth import configure_auth
REDIRECTS = { REDIRECTS = {
"/": "/docs/swagger", "/": "/docs/swagger",
} }
@ -25,8 +28,13 @@ app.config.CORS_SUPPORTS_CREDENTIALS = True
app.config.CORS_SEND_WILDCARD = True app.config.CORS_SEND_WILDCARD = True
app.config.CORS_ORIGINS = "*" app.config.CORS_ORIGINS = "*"
app.blueprint(api) # sanic-beskar
configure_auth(app)
# sanic-limiter
configure_limiter(app)
app.blueprint(api)
# https://sanic.dev/en/guide/how-to/static-redirects.html # https://sanic.dev/en/guide/how-to/static-redirects.html

40
auth.py Normal file
View File

@ -0,0 +1,40 @@
import os
import secrets
import string
from data.database import Session
from sanic_beskar import Beskar
from data.models import UserDbModel
beskar = Beskar()
def configure_auth(app):
app.config.SECRET_KEY = os.environ.get("SECRET_KEY").join(
secrets.choice(string.ascii_letters) for i in range(15)
)
app.config["TOKEN_ACCESS_LIFESPAN"] = {"hours": 24}
app.config["TOKEN_REFRESH_LIFESPAN"] = {"days": 30}
beskar.init_app(app, UserDbModel)
_init_default_user()
def _init_default_user():
username = os.environ.get("USERNAME")
password = os.environ.get("PASSWORD")
if not username or not password:
raise Exception("Missing USERNAME or PASSWORD environment variables")
session = Session()
existing_user = session.query(UserDbModel).filter_by(username=username).first()
if not existing_user:
session.add(
UserDbModel(username=username, password=beskar.hash_password(password))
)
session.commit()
session.close()

6
data/database.py Normal file
View File

@ -0,0 +1,6 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("sqlite:///persistance/database.db")
Session = sessionmaker(bind=engine)

77
data/models.py Normal file
View File

@ -0,0 +1,77 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy import ForeignKey
from data.database import Session, engine
Base = declarative_base()
class AnnouncementDbModel(Base):
__tablename__ = "announcements"
id = Column(Integer, primary_key=True, autoincrement=True)
author = Column(String, nullable=True)
title = Column(String, nullable=False)
message = Column(String, nullable=True)
attachments = relationship("AttachmentDbModel", back_populates="announcements")
channel = Column(String, nullable=True)
created_at = Column(DateTime, nullable=False)
level = Column(Integer, nullable=True)
class AttachmentDbModel(Base):
__tablename__ = "attachments"
id = Column(Integer, primary_key=True, autoincrement=True)
announcement_id = Column(Integer, ForeignKey("announcements.id"))
attachment_url = Column(String, nullable=False)
announcements = relationship("AnnouncementDbModel", back_populates="attachments")
class UserDbModel(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String, nullable=False)
password = Column(String, nullable=False)
# Required by sanic-beskar
@property
def rolenames(self):
return []
@classmethod
async def lookup(cls, username=None):
try:
session = Session()
user = session.query(UserDbModel).filter_by(username=username).first()
session.close()
return user
except:
return None
@classmethod
async def identify(cls, id):
try:
session = Session()
user = session.query(UserDbModel).filter_by(id=id).first()
session.close()
return user
except:
return None
@property
def identity(self):
return self.id
Base.metadata.create_all(engine)

View File

@ -4,8 +4,13 @@ services:
revanced-api: revanced-api:
container_name: revanced-api container_name: revanced-api
image: ghcr.io/revanced/revanced-api:latest image: ghcr.io/revanced/revanced-api:latest
volumes:
- /data/revanced-api:/usr/src/app/persistence
environment: environment:
- GITHUB_TOKEN=YOUR_GITHUB_TOKEN - GITHUB_TOKEN=YOUR_GITHUB_TOKEN
- SECRET_KEY=YOUR_SECRET_KEY
- USERNAME=YOUR_USERNAME
- PASSWORD=YOUR_PASSWORD
ports: ports:
- 127.0.0.1:7934:8000 - 127.0.0.1:7934:8000
restart: unless-stopped restart: unless-stopped

7
limiter.py Normal file
View File

@ -0,0 +1,7 @@
from sanic_limiter import Limiter, get_remote_address
limiter = Limiter(key_func=get_remote_address)
def configure_limiter(app):
limiter.init_app(app)

0
persistance/.gitkeep Normal file
View File

1260
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -28,12 +28,18 @@ pytest-emoji = "^0.2.0"
coverage = "^7.3.2" coverage = "^7.3.2"
pytest-cov = "^4.1.0" pytest-cov = "^4.1.0"
pytest = "^7.4.0" pytest = "^7.4.0"
sqlalchemy = "^2.0.21"
sanic-beskar = "^2.2.12"
bson = "^0.5.10"
fastpbkdf2 = "^0.2"
cryptography = "^41.0.4"
sanic-limiter = { git = "https://github.com/Omegastick/sanic-limiter" }
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
filterwarnings = [ filterwarnings = [
"ignore::DeprecationWarning", "ignore::DeprecationWarning",
"ignore::pytest.PytestCollectionWarning" "ignore::pytest.PytestCollectionWarning",
] ]
[build-system] [build-system]

View File

@ -1,50 +1,75 @@
aiodns==3.0.0 ; python_version >= "3.11" and python_version < "4.0" aiodns==3.1.0 ; python_version >= "3.11" and python_version < "4.0"
aiofiles==23.2.1 ; python_version >= "3.11" and python_version < "4.0" aiofiles==23.2.1 ; python_version >= "3.11" and python_version < "4.0"
aiohttp[speedups]==3.8.5 ; python_version >= "3.11" and python_version < "4.0" aiohttp[speedups]==3.8.6 ; python_version >= "3.11" and python_version < "4.0"
aiosignal==1.3.1 ; python_version >= "3.11" and python_version < "4.0" aiosignal==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
anyio==4.0.0 ; python_version >= "3.11" and python_version < "4.0" anyio==4.0.0 ; python_version >= "3.11" and python_version < "4.0"
argon2-cffi==23.1.0 ; python_version >= "3.11" and python_version < "4.0"
argon2-cffi-bindings==21.2.0 ; python_version >= "3.11" and python_version < "4.0"
async-timeout==4.0.3 ; python_version >= "3.11" and python_version < "4.0" async-timeout==4.0.3 ; python_version >= "3.11" and python_version < "4.0"
asyncstdlib==3.10.8 ; python_version >= "3.11" and python_version < "4.0" asyncstdlib==3.10.9 ; python_version >= "3.11" and python_version < "4.0"
attrs==23.1.0 ; python_version >= "3.11" and python_version < "4.0" attrs==23.1.0 ; python_version >= "3.11" and python_version < "4.0"
beautifulsoup4==4.12.2 ; python_version >= "3.11" and python_version < "4.0" beautifulsoup4==4.12.2 ; python_version >= "3.11" and python_version < "4.0"
brotli==1.1.0 ; python_version >= "3.11" and python_version < "4.0" brotli==1.1.0 ; python_version >= "3.11" and python_version < "4.0"
bson==0.5.10 ; python_version >= "3.11" and python_version < "4.0"
certifi==2023.7.22 ; python_version >= "3.11" and python_version < "4.0" certifi==2023.7.22 ; python_version >= "3.11" and python_version < "4.0"
cffi==1.15.1 ; python_version >= "3.11" and python_version < "4.0" cffi==1.16.0 ; python_version >= "3.11" and python_version < "4.0"
charset-normalizer==3.2.0 ; python_version >= "3.11" and python_version < "4.0" charset-normalizer==3.3.0 ; python_version >= "3.11" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and sys_platform == "win32" colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and sys_platform == "win32"
coverage==7.3.1 ; python_version >= "3.11" and python_version < "4.0" coverage==7.3.2 ; python_version >= "3.11" and python_version < "4.0"
coverage[toml]==7.3.1 ; python_version >= "3.11" and python_version < "4.0" coverage[toml]==7.3.2 ; python_version >= "3.11" and python_version < "4.0"
cryptography==41.0.4 ; python_version >= "3.11" and python_version < "4.0"
cytoolz==0.12.2 ; python_version >= "3.11" and python_version < "4.0" cytoolz==0.12.2 ; python_version >= "3.11" and python_version < "4.0"
deprecated==1.2.14 ; python_version >= "3.11" and python_version < "4.0"
fastpbkdf2==0.2 ; python_version >= "3.11" and python_version < "4.0"
frozenlist==1.4.0 ; python_version >= "3.11" and python_version < "4.0" frozenlist==1.4.0 ; python_version >= "3.11" and python_version < "4.0"
greenlet==3.0.0 ; python_version >= "3.11" and python_version < "4.0" and platform_machine == "aarch64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "ppc64le" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "x86_64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "amd64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "AMD64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "win32" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "WIN32"
h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0" h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0"
html5tagger==1.3.0 ; python_version >= "3.11" and python_version < "4.0" html5tagger==1.3.0 ; python_version >= "3.11" and python_version < "4.0"
httpcore==0.18.0 ; python_version >= "3.11" and python_version < "4.0" httpcore==0.18.0 ; python_version >= "3.11" and python_version < "4.0"
httptools==0.6.0 ; python_version >= "3.11" and python_version < "4.0" httptools==0.6.0 ; python_version >= "3.11" and python_version < "4.0"
httpx==0.25.0 ; python_version >= "3.11" and python_version < "4.0" httpx==0.25.0 ; python_version >= "3.11" and python_version < "4.0"
idna==3.4 ; python_version >= "3.11" and python_version < "4.0" idna==3.4 ; python_version >= "3.11" and python_version < "4.0"
importlib-resources==6.1.0 ; python_version >= "3.11" and python_version < "4.0"
iniconfig==2.0.0 ; python_version >= "3.11" and python_version < "4.0" iniconfig==2.0.0 ; python_version >= "3.11" and python_version < "4.0"
iso8601==2.1.0 ; python_version >= "3.11" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.11" and python_version < "4.0"
limits==3.6.0 ; python_version >= "3.11" and python_version < "4.0"
lxml==4.9.3 ; python_version >= "3.11" and python_version < "4.0" lxml==4.9.3 ; python_version >= "3.11" and python_version < "4.0"
markupsafe==2.1.3 ; python_version >= "3.11" and python_version < "4.0"
multidict==6.0.4 ; python_version >= "3.11" and python_version < "4.0" multidict==6.0.4 ; python_version >= "3.11" and python_version < "4.0"
mypy==1.5.1 ; python_version >= "3.11" and python_version < "4.0" mypy==1.6.0 ; python_version >= "3.11" and python_version < "4.0"
mypy-extensions==1.0.0 ; python_version >= "3.11" and python_version < "4.0" mypy-extensions==1.0.0 ; python_version >= "3.11" and python_version < "4.0"
packaging==23.1 ; python_version >= "3.11" and python_version < "4.0" packaging==23.2 ; python_version >= "3.11" and python_version < "4.0"
passlib==1.7.4 ; python_version >= "3.11" and python_version < "4.0"
pendulum==2.1.2 ; python_version >= "3.11" and python_version < "4.0"
pluggy==1.3.0 ; python_version >= "3.11" and python_version < "4.0" pluggy==1.3.0 ; python_version >= "3.11" and python_version < "4.0"
pycares==4.3.0 ; python_version >= "3.11" and python_version < "4.0" py-buzz==4.1.0 ; python_version >= "3.11" and python_version < "4.0"
pycares==4.4.0 ; python_version >= "3.11" and python_version < "4.0"
pycparser==2.21 ; python_version >= "3.11" and python_version < "4.0" pycparser==2.21 ; python_version >= "3.11" and python_version < "4.0"
pydantic==1.10.12 ; python_version >= "3.11" and python_version < "4.0" pycryptodomex==3.19.0 ; python_version >= "3.11" and python_version < "4.0"
pydantic==1.10.13 ; python_version >= "3.11" and python_version < "4.0"
pyjwt==2.8.0 ; python_version >= "3.11" and python_version < "4.0"
pyseto==1.7.5 ; python_version >= "3.11" and python_version < "4.0"
pytest==7.4.2 ; python_version >= "3.11" and python_version < "4.0" pytest==7.4.2 ; python_version >= "3.11" and python_version < "4.0"
pytest-asyncio==0.21.1 ; python_version >= "3.11" and python_version < "4.0" pytest-asyncio==0.21.1 ; python_version >= "3.11" and python_version < "4.0"
pytest-cov==4.1.0 ; python_version >= "3.11" and python_version < "4.0" pytest-cov==4.1.0 ; python_version >= "3.11" and python_version < "4.0"
pytest-emoji==0.2.0 ; python_version >= "3.11" and python_version < "4.0" pytest-emoji==0.2.0 ; python_version >= "3.11" and python_version < "4.0"
pytest-md==0.2.0 ; python_version >= "3.11" and python_version < "4.0" pytest-md==0.2.0 ; python_version >= "3.11" and python_version < "4.0"
python-dateutil==2.8.2 ; python_version >= "3.11" and python_version < "4.0"
pytzdata==2020.1 ; python_version >= "3.11" and python_version < "4.0"
pyyaml==6.0.1 ; python_version >= "3.11" and python_version < "4.0" pyyaml==6.0.1 ; python_version >= "3.11" and python_version < "4.0"
sanic==23.6.0 ; python_version >= "3.11" and python_version < "4.0"
sanic-beskar==2.2.12 ; python_version >= "3.11" and python_version < "4.0"
sanic-ext==23.6.0 ; python_version >= "3.11" and python_version < "4.0" sanic-ext==23.6.0 ; python_version >= "3.11" and python_version < "4.0"
sanic-limiter @ git+https://github.com/Omegastick/sanic-limiter ; python_version >= "3.11" and python_version < "4.0"
sanic-routing==23.6.0 ; python_version >= "3.11" and python_version < "4.0" sanic-routing==23.6.0 ; python_version >= "3.11" and python_version < "4.0"
sanic-testing==23.6.0 ; python_version >= "3.11" and python_version < "4.0" sanic-testing==23.6.0 ; python_version >= "3.11" and python_version < "4.0"
sanic[ext]==23.6.0 ; python_version >= "3.11" and python_version < "4.0" sanic[ext]==23.6.0 ; python_version >= "3.11" and python_version < "4.0"
setuptools==68.2.2 ; python_version >= "3.11" and python_version < "4.0" setuptools==68.2.2 ; python_version >= "3.11" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.11" and python_version < "4.0"
sniffio==1.3.0 ; python_version >= "3.11" and python_version < "4.0" sniffio==1.3.0 ; python_version >= "3.11" and python_version < "4.0"
soupsieve==2.5 ; python_version >= "3.11" and python_version < "4.0" soupsieve==2.5 ; python_version >= "3.11" and python_version < "4.0"
sqlalchemy==2.0.21 ; python_version >= "3.11" and python_version < "4.0"
toolz==0.12.0 ; python_version >= "3.11" and python_version < "4.0" toolz==0.12.0 ; python_version >= "3.11" and python_version < "4.0"
tracerite==1.1.0 ; python_version >= "3.11" and python_version < "4.0" tracerite==1.1.0 ; python_version >= "3.11" and python_version < "4.0"
types-aiofiles==23.2.0.0 ; python_version >= "3.11" and python_version < "4.0" types-aiofiles==23.2.0.0 ; python_version >= "3.11" and python_version < "4.0"
@ -55,4 +80,5 @@ typing-extensions==4.8.0 ; python_version >= "3.11" and python_version < "4.0"
ujson==5.8.0 ; python_version >= "3.11" and python_version < "4.0" ujson==5.8.0 ; python_version >= "3.11" and python_version < "4.0"
uvloop==0.17.0 ; sys_platform != "win32" and implementation_name == "cpython" and python_version >= "3.11" and python_version < "4.0" uvloop==0.17.0 ; sys_platform != "win32" and implementation_name == "cpython" and python_version >= "3.11" and python_version < "4.0"
websockets==11.0.3 ; python_version >= "3.11" and python_version < "4.0" websockets==11.0.3 ; python_version >= "3.11" and python_version < "4.0"
wrapt==1.15.0 ; python_version >= "3.11" and python_version < "4.0"
yarl==1.9.2 ; python_version >= "3.11" and python_version < "4.0" yarl==1.9.2 ; python_version >= "3.11" and python_version < "4.0"