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
uses: actions/setup-python@v4
with:
python-version: "3.11.4"
python-version: "3.11.6"
- name: Install project dependencies
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
env.sh
persistance/database.db

View File

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

View File

@ -4,7 +4,7 @@ FROM python:3.11-slim as dependencies
WORKDIR /usr/src/app
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/*
RUN python -m venv /opt/venv
@ -28,6 +28,8 @@ ENV PATH="/opt/venv/bin:$PATH"
COPY --from=dependencies /opt/venv /opt/venv
COPY . .
VOLUME persistance
CMD docker/run-backend.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
```
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:

View File

@ -7,5 +7,9 @@ from api.socials import socials
from api.info import info
from api.compat import github as compat
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 config import *
from limiter import configure_limiter
from auth import configure_auth
REDIRECTS = {
"/": "/docs/swagger",
}
@ -25,8 +28,13 @@ app.config.CORS_SUPPORTS_CREDENTIALS = True
app.config.CORS_SEND_WILDCARD = True
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

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:
container_name: revanced-api
image: ghcr.io/revanced/revanced-api:latest
volumes:
- /data/revanced-api:/usr/src/app/persistence
environment:
- GITHUB_TOKEN=YOUR_GITHUB_TOKEN
- SECRET_KEY=YOUR_SECRET_KEY
- USERNAME=YOUR_USERNAME
- PASSWORD=YOUR_PASSWORD
ports:
- 127.0.0.1:7934:8000
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,13 +28,19 @@ pytest-emoji = "^0.2.0"
coverage = "^7.3.2"
pytest-cov = "^4.1.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]
asyncio_mode = "auto"
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::pytest.PytestCollectionWarning"
]
"ignore::pytest.PytestCollectionWarning",
]
[build-system]
requires = ["poetry-core"]

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"
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"
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"
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"
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"
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"
cffi==1.15.1 ; python_version >= "3.11" and python_version < "4.0"
charset-normalizer==3.2.0 ; 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.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"
coverage==7.3.1 ; python_version >= "3.11" and python_version < "4.0"
coverage[toml]==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.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"
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"
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"
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"
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"
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"
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"
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"
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"
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"
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"
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-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-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"
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"
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-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-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"
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"
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"
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"
@ -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"
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"
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"